added plantUML support

fixed bug with subscription being notified multiple times
This commit is contained in:
2024-10-23 20:57:20 +08:00
parent a4b3d10c66
commit 7d9cae76c1
15 changed files with 424 additions and 163 deletions

View File

@@ -32,7 +32,8 @@ VOLUME /srv/http
WORKDIR /srv/http WORKDIR /srv/http
ENV GRANIAN_HOST=0.0.0.0 ENV GRANIAN_HOST=0.0.0.0
ENV GRANIAN_INTERFACE=asginl ENV GRANIAN_PORT=8000
ENV GRANIAN_INTERFACE=asgi
ENV GRANIAN_LOOP=asyncio ENV GRANIAN_LOOP=asyncio
ENV GRANIAN_LOG_ENABLED=false ENV GRANIAN_LOG_ENABLED=false

View File

@@ -1,9 +0,0 @@
#!/bin/bash
set -e
venv/bin/python -m build
mkdir -p docker/build
cp dist/bugis-*.whl docker/build/
cp docker/Dockerfile docker/build/Dockerfile
docker build docker/build --tag bugis:latest

View File

@@ -1,18 +0,0 @@
version: 1
disable_existing_loggers: True
handlers:
console:
class : logging.StreamHandler
formatter: default
level : INFO
stream : ext://sys.stdout
formatters:
brief:
format: '%(message)s'
default:
format: '%(asctime)s %(levelname)-8s %(name)-15s %(threadName)s %(message)s'
datefmt: '%Y-%m-%d %H:%M:%S'
loggers:
root:
handlers: [console]
level: INFO

46
docker-compose.yaml Normal file
View File

@@ -0,0 +1,46 @@
networks:
default:
external: false
ipam:
driver: default
config:
- subnet: 172.128.0.0/16
ip_range: 172.128.0.0/16
gateway: 172.128.0.254
services:
granian:
build:
context: .
user: $UID:$GID
restart: unless-stopped
# container_name: granian
environment:
PLANT_UML_SERVER_ADDRESS: http://plant_uml:8080
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
volumes:
- ${STATIC_ROOT}:/srv/http
plant_uml:
image: plantuml/plantuml-server:jetty
# container_name: plantUML
restart: unless-stopped
tmpfs: /tmp/jetty
deploy:
resources:
limits:
cpus: "4"
memory: 1G
nginx:
image: gitea.woggioni.net/woggioni/nginx:v1.27.2
# container_name: nginx
restart: unless-stopped
depends_on:
- granian
volumes:
- ./conf/nginx-bugis.conf:/etc/nginx/conf.d/bugis.conf:ro
ports:
- 127.0.0.1:80:8080

View File

@@ -28,7 +28,8 @@ dependencies = [
"pwo", "pwo",
"PyYAML", "PyYAML",
"pygraphviz", "pygraphviz",
"aiofiles" "aiofiles",
"aiohttp[speedups]"
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1,22 +1,39 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml # pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=dev --output-file=requirements-dev.txt --strip-extras pyproject.toml
# #
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
aiodns==3.2.0
# via aiohttp
aiofiles==24.1.0 aiofiles==24.1.0
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
aiohappyeyeballs==2.4.3
# via aiohttp
aiohttp==3.10.10
# via bugis (pyproject.toml)
aiosignal==1.3.1
# via aiohttp
asttokens==2.4.1 asttokens==2.4.1
# via stack-data # via stack-data
async-timeout==4.0.3
# via aiohttp
attrs==24.2.0
# via aiohttp
backports-tarfile==1.2.0
# via jaraco-context
brotli==1.1.0
# via aiohttp
build==1.2.2.post1 build==1.2.2.post1
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
certifi==2024.8.30 certifi==2024.8.30
# via requests # via requests
cffi==1.17.1 cffi==1.17.1
# via cryptography # via
# cryptography
# pycares
charset-normalizer==3.4.0 charset-normalizer==3.4.0
# via requests # via requests
click==8.1.7 click==8.1.7
@@ -29,14 +46,24 @@ decorator==5.1.1
# ipython # ipython
docutils==0.21.2 docutils==0.21.2
# via readme-renderer # via readme-renderer
exceptiongroup==1.2.2
# via ipython
executing==2.1.0 executing==2.1.0
# via stack-data # via stack-data
frozenlist==1.4.1
# via
# aiohttp
# aiosignal
granian==1.6.1 granian==1.6.1
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
idna==3.10 idna==3.10
# via requests # via
# requests
# yarl
importlib-metadata==8.5.0 importlib-metadata==8.5.0
# via twine # via
# keyring
# twine
ipdb==0.13.13 ipdb==0.13.13
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
ipython==8.28.0 ipython==8.28.0
@@ -67,6 +94,10 @@ more-itertools==10.5.0
# via # via
# jaraco-classes # jaraco-classes
# jaraco-functools # jaraco-functools
multidict==6.1.0
# via
# aiohttp
# yarl
mypy==1.12.1 mypy==1.12.1
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
mypy-extensions==1.0.0 mypy-extensions==1.0.0
@@ -83,12 +114,16 @@ pkginfo==1.10.0
# via twine # via twine
prompt-toolkit==3.0.48 prompt-toolkit==3.0.48
# via ipython # via ipython
propcache==0.2.0
# via yarl
ptyprocess==0.7.0 ptyprocess==0.7.0
# via pexpect # via pexpect
pure-eval==0.2.3 pure-eval==0.2.3
# via stack-data # via stack-data
pwo==0.0.3 pwo==0.0.3
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
pycares==4.4.0
# via aiodns
pycparser==2.22 pycparser==2.22
# via cffi # via cffi
pygments==2.18.0 pygments==2.18.0
@@ -121,6 +156,11 @@ six==1.16.0
# via asttokens # via asttokens
stack-data==0.6.3 stack-data==0.6.3
# via ipython # via ipython
tomli==2.0.2
# via
# build
# ipdb
# mypy
traitlets==5.14.3 traitlets==5.14.3
# via # via
# ipython # ipython
@@ -129,8 +169,11 @@ twine==5.1.1
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
typing-extensions==4.7.1 typing-extensions==4.7.1
# via # via
# ipython
# multidict
# mypy # mypy
# pwo # pwo
# rich
urllib3==2.2.3 urllib3==2.2.3
# via # via
# requests # requests
@@ -141,5 +184,7 @@ watchdog==5.0.3
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
wcwidth==0.2.13 wcwidth==0.2.13
# via prompt-toolkit # via prompt-toolkit
yarl==1.16.0
# via aiohttp
zipp==3.20.2 zipp==3.20.2
# via importlib-metadata # via importlib-metadata

View File

@@ -1,22 +1,53 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --extra=run --output-file=requirements-run.txt pyproject.toml # pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=run --output-file=requirements-run.txt --strip-extras pyproject.toml
# #
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
aiodns==3.2.0
# via aiohttp
aiofiles==24.1.0 aiofiles==24.1.0
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
aiohappyeyeballs==2.4.3
# via aiohttp
aiohttp==3.10.10
# via bugis (pyproject.toml)
aiosignal==1.3.1
# via aiohttp
async-timeout==4.0.3
# via aiohttp
attrs==24.2.0
# via aiohttp
brotli==1.1.0
# via aiohttp
cffi==1.17.1
# via pycares
click==8.1.7 click==8.1.7
# via granian # via granian
frozenlist==1.4.1
# via
# aiohttp
# aiosignal
granian==1.6.1 granian==1.6.1
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
idna==3.10
# via yarl
markdown==3.7 markdown==3.7
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
multidict==6.1.0
# via
# aiohttp
# yarl
propcache==0.2.0
# via yarl
pwo==0.0.3 pwo==0.0.3
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
pycares==4.4.0
# via aiodns
pycparser==2.22
# via cffi
pygments==2.18.0 pygments==2.18.0
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
pygraphviz==1.14 pygraphviz==1.14
@@ -24,8 +55,12 @@ pygraphviz==1.14
pyyaml==6.0.2 pyyaml==6.0.2
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
typing-extensions==4.7.1 typing-extensions==4.7.1
# via pwo # via
# multidict
# pwo
uvloop==0.21.0 uvloop==0.21.0
# via granian # via granian
watchdog==5.0.3 watchdog==5.0.3
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
yarl==1.16.0
# via aiohttp

View File

@@ -1,18 +1,49 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --extra='' --output-file=requirements-.txt pyproject.toml # pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=requirements.txt --strip-extras pyproject.toml
# #
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
aiodns==3.2.0
# via aiohttp
aiofiles==24.1.0 aiofiles==24.1.0
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
aiohappyeyeballs==2.4.3
# via aiohttp
aiohttp==3.10.10
# via bugis (pyproject.toml)
aiosignal==1.3.1
# via aiohttp
async-timeout==4.0.3
# via aiohttp
attrs==24.2.0
# via aiohttp
brotli==1.1.0
# via aiohttp
cffi==1.17.1
# via pycares
frozenlist==1.4.1
# via
# aiohttp
# aiosignal
idna==3.10
# via yarl
markdown==3.7 markdown==3.7
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
multidict==6.1.0
# via
# aiohttp
# yarl
propcache==0.2.0
# via yarl
pwo==0.0.3 pwo==0.0.3
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
pycares==4.4.0
# via aiodns
pycparser==2.22
# via cffi
pygments==2.18.0 pygments==2.18.0
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
pygraphviz==1.14 pygraphviz==1.14
@@ -20,6 +51,10 @@ pygraphviz==1.14
pyyaml==6.0.2 pyyaml==6.0.2
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
typing-extensions==4.7.1 typing-extensions==4.7.1
# via pwo # via
# multidict
# pwo
watchdog==5.0.3 watchdog==5.0.3
# via bugis (pyproject.toml) # via bugis (pyproject.toml)
yarl==1.16.0
# via aiohttp

View File

@@ -1,11 +1,11 @@
import logging import logging
from logging.config import dictConfig as configure_logging from logging.config import dictConfig as configure_logging
from os import environ
from yaml import safe_load
from pathlib import Path
logging_configuration_file = environ.get("LOGGING_CONFIGURATION_FILE", Path(__file__).parent / 'default-conf' / 'logging.yaml') from yaml import safe_load
with open(logging_configuration_file, 'r') as input_file:
from .configuration import Configuration
with open(Configuration.instance.logging_configuration_file, 'r') as input_file:
conf = safe_load(input_file) conf = safe_load(input_file)
configure_logging(conf) configure_logging(conf)
@@ -13,55 +13,51 @@ with open(logging_configuration_file, 'r') as input_file:
from pwo import Maybe from pwo import Maybe
from .server import Server from .server import Server
from asyncio import get_running_loop from asyncio import get_running_loop
from .asgi_utils import decode_headers
from typing import Optional
log = logging.getLogger('access') log = logging.getLogger('access')
log.propagate = False log.propagate = False
_server = None _server : Optional[Server] = None
def decode_headers(headers): async def application(scope, receive, send):
result = dict()
for key, value in headers:
if isinstance(key, bytes):
key = key.decode()
if isinstance(value, bytes):
value = value.decode()
l = result.setdefault(key, list())
l.append(value)
return {
k: tuple(v) for k, v in result.items()
}
async def application(ctx, receive, send):
global _server global _server
if _server is None: if scope['type'] == 'lifespan':
_server = Server(loop=get_running_loop(), prefix=None) while True:
message = await receive()
def maybe_log(evt): if message['type'] == 'lifespan.startup':
d = { _server = Server(loop=get_running_loop(), prefix=None)
'response_headers': (Maybe.of_nullable(evt.get('headers')) await send({'type': 'lifespan.startup.complete'})
.map(decode_headers) elif message['type'] == 'lifespan.shutdown':
.or_none()), await _server.stop()
'status': evt['status'] await send({'type': 'lifespan.shutdown.complete'})
} else:
log.info(None, extra=dict(**{k : v for k, v in d.items() if k is not None}, **ctx)) def maybe_log(evt):
def wrapped_send(*args, **kwargs): d = {
result = send(*args, **kwargs) 'response_headers': (Maybe.of_nullable(evt.get('headers'))
(Maybe.of(args) .map(decode_headers)
.filter(lambda it: len(it) > 0) .or_none()),
.map(lambda it: it[0]) 'status': evt['status']
.filter(lambda it: it.get('type') == 'http.response.start') }
.if_present(maybe_log)) log.info(None, extra=dict(**{k : v for k, v in d.items() if k is not None}, **scope))
return result def wrapped_send(*args, **kwargs):
await _server.handle_request( result = send(*args, **kwargs)
ctx['method'], (Maybe.of(args)
ctx['path'], .filter(lambda it: len(it) > 0)
Maybe.of([header[1] for header in ctx['headers'] if header[0].decode().lower() == 'if-none-match']) .map(lambda it: it[0])
.filter(lambda it: len(it) > 0) .filter(lambda it: it.get('type') == 'http.response.start')
.map(lambda it: it[0]) .if_present(maybe_log))
.map(lambda it: it.decode()) return result
.or_else(None), await _server.handle_request(
Maybe.of_nullable(ctx.get('query_string', None)).map(lambda it: it.decode()).or_else(None), scope['method'],
wrapped_send scope['path'],
) Maybe.of([header[1] for header in scope['headers'] if header[0].decode().lower() == 'if-none-match'])
.filter(lambda it: len(it) > 0)
.map(lambda it: it[0])
.map(lambda it: it.decode())
.or_else(None),
Maybe.of_nullable(scope.get('query_string', None)).map(lambda it: it.decode()).or_else(None),
wrapped_send
)

26
src/bugis/asgi_utils.py Normal file
View File

@@ -0,0 +1,26 @@
from typing import Tuple, Dict, Sequence
type StrOrStrings = (str, Sequence[str])
def decode_headers(headers: Sequence[Tuple[bytes, bytes]]) -> Dict[str, Sequence[str]]:
result = dict()
for key, value in headers:
if isinstance(key, bytes):
key = key.decode()
if isinstance(value, bytes):
value = value.decode()
l = result.setdefault(key, list())
l.append(value)
return {
k: tuple(v) for k, v in result.items()
}
def encode_headers(headers: Dict[str, StrOrStrings]) -> Tuple[Tuple[bytes, bytes], ...]:
result = []
for key, value in headers.items():
if isinstance(value, str):
result.append((key.encode(), value.encode()))
elif isinstance(value, Sequence):
for single_value in value:
result.append((key.encode(), single_value.encode()))
return tuple(result)

View File

@@ -4,33 +4,38 @@ from watchdog.events import FileSystemEventHandler, FileSystemEvent, PatternMatc
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileMovedEvent, FileClosedEvent, FileCreatedEvent, FileModifiedEvent from watchdog.events import FileMovedEvent, FileClosedEvent, FileCreatedEvent, FileModifiedEvent
from pathlib import Path from pathlib import Path
from asyncio import Queue, AbstractEventLoop, Future, CancelledError, Task from asyncio import Queue, AbstractEventLoop, Future, CancelledError, Task, gather
from typing import Callable from typing import Callable, Optional
from logging import getLogger, Logger from logging import getLogger, Logger
log: Logger = getLogger(__name__) log: Logger = getLogger(__name__)
class Subscription: class Subscription:
_unsubscribe_callback: Callable[['Subscription'], None] _unsubscribe_callback: Callable[['Subscription'], None]
_event: Future _event: Optional[Future]
_loop: AbstractEventLoop _loop: AbstractEventLoop
def __init__(self, unsubscribe: Callable[['Subscription'], None], loop: AbstractEventLoop): def __init__(self, unsubscribe: Callable[['Subscription'], None], loop: AbstractEventLoop):
self._unsubscribe_callback = unsubscribe self._unsubscribe_callback = unsubscribe
self._event: Future = loop.create_future() self._event: Optional[Future] = None
self._loop = loop self._loop = loop
def unsubscribe(self) -> None: def unsubscribe(self) -> None:
self._event.cancel()
self._unsubscribe_callback(self) self._unsubscribe_callback(self)
log.debug('Deleted subscription %s', id(self)) log.debug('Deleted subscription %s', id(self))
async def wait(self, tout: float) -> bool: async def wait(self, tout: float) -> bool:
handle = self._loop.call_later(tout, lambda: self._event.cancel()) self._event = self._loop.create_future()
def callback():
if not self._event.done():
self._event.set_result(False)
handle = self._loop.call_later(tout, callback)
try: try:
log.debug('Subscription %s is waiting for an event', id(self)) log.debug('Subscription %s is waiting for an event', id(self))
await self._event return await self._event
return True
except CancelledError: except CancelledError:
return False return False
finally: finally:
@@ -38,7 +43,8 @@ class Subscription:
def notify(self) -> None: def notify(self) -> None:
log.debug('Subscription %s notified', id(self)) log.debug('Subscription %s notified', id(self))
self._event.set_result(None) if not self._event.done():
self._event.set_result(True)
def reset(self) -> None: def reset(self) -> None:
self._event = self._loop.create_future() self._event = self._loop.create_future()
@@ -106,6 +112,7 @@ class SubscriptionManager:
def unsubscribe_callback(subscription): def unsubscribe_callback(subscription):
subscriptions_per_path.remove(subscription) subscriptions_per_path.remove(subscription)
log.debug('Removed subscription %s to path %s', id(result), path)
result = Subscription(unsubscribe_callback, self._loop) result = Subscription(unsubscribe_callback, self._loop)
log.debug('Created subscription %s to path %s', id(result), path) log.debug('Created subscription %s to path %s', id(result), path)
@@ -116,21 +123,29 @@ class SubscriptionManager:
subscriptions = self._subscriptions subscriptions = self._subscriptions
subscriptions_per_path = subscriptions.get(path, None) subscriptions_per_path = subscriptions.get(path, None)
if subscriptions_per_path: if subscriptions_per_path:
log.debug(f"Subscriptions on '{path}': {len(subscriptions_per_path)}")
for s in subscriptions_per_path: for s in subscriptions_per_path:
s.notify() s.notify()
async def process_events(self): async def process_events(self):
async for evt in AsyncQueueIterator(self._queue): async for evt in AsyncQueueIterator(self._queue):
log.debug(f"Processed event for path '{evt}'")
self._notify_subscriptions(evt) self._notify_subscriptions(evt)
log.debug(f"Event processor has completed")
def post_event(self, path): def post_event(self, path):
self._loop.call_soon_threadsafe(self._queue.put_nowait, path) def callback():
self._queue.put_nowait(path)
log.debug(f"Posted event for path '{path}', queue size: {self._queue.qsize()}")
self._loop.call_soon_threadsafe(callback)
class FileWatcher(PatternMatchingEventHandler): class FileWatcher(PatternMatchingEventHandler):
_subscription_manager: SubscriptionManager _subscription_manager: SubscriptionManager
_loop: AbstractEventLoop _loop: AbstractEventLoop
_subscription_manager_loop: Task _subscription_manager_loop: Task
_running_tasks : Future
def __init__(self, path): def __init__(self, path):
super().__init__(patterns=['*.md'], super().__init__(patterns=['*.md'],
@@ -141,8 +156,10 @@ class FileWatcher(PatternMatchingEventHandler):
self._observer.schedule(self, path=path, recursive=True) self._observer.schedule(self, path=path, recursive=True)
self._loop = asyncio.get_running_loop() self._loop = asyncio.get_running_loop()
self._subscription_manager = SubscriptionManager(self._loop) self._subscription_manager = SubscriptionManager(self._loop)
self._loop.run_in_executor(None, self._observer.start) self._running_tasks = gather(
self._subscription_manager_loop = self._loop.create_task(self._subscription_manager.process_events()) self._loop.run_in_executor(None, self._observer.start),
self._loop.create_task(self._subscription_manager.process_events())
)
async def stop(self) -> None: async def stop(self) -> None:
def _observer_stop(): def _observer_stop():
@@ -150,8 +167,8 @@ class FileWatcher(PatternMatchingEventHandler):
self._observer.join() self._observer.join()
self._subscription_manager.post_event(None) self._subscription_manager.post_event(None)
self._loop.run_in_executor(None, _observer_stop) await self._loop.run_in_executor(None, _observer_stop)
await self._subscription_manager_loop await self._running_tasks
def subscribe(self, path: str) -> Subscription: def subscribe(self, path: str) -> Subscription:
return self._subscription_manager.subscribe(path) return self._subscription_manager.subscribe(path)

View File

@@ -0,0 +1,45 @@
import os
from os import environ
from pathlib import Path
from dataclasses import dataclass
class ClassPropertyDescriptor(object):
def __init__(self, fget, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, obj, klass=None):
if klass is None:
klass = type(obj)
return self.fget.__get__(obj, klass)()
def __set__(self, obj, value):
if not self.fset:
raise AttributeError("can't set attribute")
type_ = type(obj)
return self.fset.__get__(obj, type_)(value)
def setter(self, func):
if not isinstance(func, (classmethod, staticmethod)):
func = classmethod(func)
self.fset = func
return self
def classproperty(func):
if not isinstance(func, (classmethod, staticmethod)):
func = classmethod(func)
return ClassPropertyDescriptor(func)
@dataclass(frozen=True)
class Configuration:
logging_configuration_file : str = environ.get("LOGGING_CONFIGURATION_FILE", Path(__file__).parent / 'default-conf' / 'logging.yaml')
plant_uml_server_address : str = environ.get('PLANT_UML_SERVER_ADDRESS', None)
@classproperty
def instance(cls) -> 'Configuration':
return Configuration()

17
src/bugis/plantuml.py Normal file
View File

@@ -0,0 +1,17 @@
from typing import TYPE_CHECKING
from aiofiles import open as async_open
from aiohttp import ClientSession
from .configuration import Configuration
from yarl import URL
if TYPE_CHECKING:
from _typeshed import StrOrBytesPath
async def render_plant_uml(path: 'StrOrBytesPath') -> bytes:
async with ClientSession() as session:
url = URL(Configuration.instance.plant_uml_server_address) / 'svg'
async with async_open(path, 'rb') as file:
source = await file.read()
async with session.post(url, data=source) as response:
response.raise_for_status()
return await response.read()

View File

@@ -1,25 +1,26 @@
import logging
from os import getcwd
from mimetypes import init as mimeinit, guess_type
import hashlib import hashlib
import logging
from os.path import join, normpath, splitext, relpath, basename from asyncio import AbstractEventLoop
from asyncio import Future from asyncio import Future
from aiofiles.os import listdir from io import BytesIO
from aiofiles.ospath import exists, isdir, isfile, getmtime from mimetypes import init as mimeinit, guess_type
from os import getcwd
from os.path import join, normpath, splitext, relpath, basename
from typing import Callable, TYPE_CHECKING, Optional, Awaitable, AsyncGenerator, Any
import pygraphviz as pgv
from aiofiles import open as async_open from aiofiles import open as async_open
from aiofiles.base import AiofilesContextManager from aiofiles.base import AiofilesContextManager
from aiofiles.os import listdir
from aiofiles.ospath import exists, isdir, isfile, getmtime
from aiofiles.threadpool.binary import AsyncBufferedReader from aiofiles.threadpool.binary import AsyncBufferedReader
from asyncio import AbstractEventLoop
from .md2html import compile_html, load_from_cache, STATIC_RESOURCES, MARDOWN_EXTENSIONS
from shutil import which
import pygraphviz as pgv
from io import BytesIO
from typing import Callable, TYPE_CHECKING, Optional, Awaitable, AsyncGenerator, Any
from .async_watchdog import FileWatcher
from pwo import Maybe from pwo import Maybe
from .asgi_utils import encode_headers
from .async_watchdog import FileWatcher
from .md2html import compile_html, load_from_cache, STATIC_RESOURCES, MARDOWN_EXTENSIONS
from .plantuml import render_plant_uml
if TYPE_CHECKING: if TYPE_CHECKING:
from _typeshed import StrOrBytesPath from _typeshed import StrOrBytesPath
@@ -45,6 +46,9 @@ def is_markdown(filepath):
def is_dotfile(filepath): def is_dotfile(filepath):
return has_extension(filepath, ".dot") return has_extension(filepath, ".dot")
def is_plant_uml(filepath):
return has_extension(filepath, ".puml")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Server: class Server:
@@ -91,11 +95,11 @@ class Server:
await send({ await send({
'type': 'http.response.start', 'type': 'http.response.start',
'status': 200, 'status': 200,
'headers': [ 'headers': encode_headers({
('content-type', f'{mime_type}; charset=UTF-8'), 'content-type': f'{mime_type}; charset=UTF-8',
('etag', f'W/"{digest}"'.encode()), 'etag': f'W/{digest}',
('Cache-Control', 'must-revalidate, max-age=86400'), 'Cache-Control': 'must-revalidate, max-age=86400',
] })
}) })
await send({ await send({
'type': 'http.response.body', 'type': 'http.response.body',
@@ -124,7 +128,7 @@ class Server:
lambda: getmtime(path) lambda: getmtime(path)
) )
if etag != digest: if etag != digest:
if exists(path) and await isfile(path): if await exists(path) and await isfile(path):
await self.render_markdown(url_path, path, True, digest, send) await self.render_markdown(url_path, path, True, digest, send)
return return
else: else:
@@ -136,7 +140,7 @@ class Server:
elif is_markdown(path): elif is_markdown(path):
raw = query_string == 'reload' raw = query_string == 'reload'
await self.render_markdown(url_path, path, raw, digest, send) await self.render_markdown(url_path, path, raw, digest, send)
elif is_dotfile(path) and which("dot"): elif is_dotfile(path):
def render_graphviz(filepath: StrOrBytesPath) -> bytes: def render_graphviz(filepath: StrOrBytesPath) -> bytes:
logger.debug("Starting Graphviz rendering for file '%s'", filepath) logger.debug("Starting Graphviz rendering for file '%s'", filepath)
graph = pgv.AGraph(filepath) graph = pgv.AGraph(filepath)
@@ -147,11 +151,28 @@ class Server:
await send({ await send({
'type': 'http.response.start', 'type': 'http.response.start',
'status': 200, 'status': 200,
'headers': ( 'headers': encode_headers({
('Content-Type', 'image/svg+xml; charset=UTF-8'), 'Content-Type': 'image/svg+xml; charset=UTF-8',
('Etag', f'W/"{digest}"'), 'Etag': f'W/{digest}',
('Cache-Control', 'no-cache'), 'Cache-Control': 'no-cache',
) })
})
await send({
'type': 'http.response.body',
'body': body
})
elif is_plant_uml(path):
logger.debug("Starting PlantUML rendering for file '%s'", path)
body = await render_plant_uml(path)
logger.debug("Completed PlantUML rendering for file '%s'", path)
await send({
'type': 'http.response.start',
'status': 200,
'headers': encode_headers({
'Content-Type': 'image/svg+xml; charset=UTF-8',
'Etag': f'W/{digest}',
'Cache-Control': 'no-cache'
})
}) })
await send({ await send({
'type': 'http.response.body', 'type': 'http.response.body',
@@ -170,11 +191,11 @@ class Server:
await send({ await send({
'type': 'http.response.start', 'type': 'http.response.start',
'status': 200, 'status': 200,
'headers': ( 'headers': encode_headers({
('Content-Type', guess_type(basename(path))[0] or 'application/octet-stream'), 'Content-Type': guess_type(basename(path))[0] or 'application/octet-stream',
('Etag', f'W/"{digest}"'), 'Etag': f'W/{digest}',
('Cache-Control', 'no-cache') 'Cache-Control': 'no-cache'
) })
}) })
async for chunk in read_file(path): async for chunk in read_file(path):
@@ -194,9 +215,9 @@ class Server:
await send({ await send({
'type': 'http.response.start', 'type': 'http.response.start',
'status': 200, 'status': 200,
'headers': ( 'headers': encode_headers({
('Content-Type', 'text/html; charset=UTF-8'), 'Content-Type': 'text/html; charset=UTF-8',
) })
}) })
await send({ await send({
'type': 'http.response.body', 'type': 'http.response.body',
@@ -288,11 +309,11 @@ class Server:
await send({ await send({
'type': 'http.response.start', 'type': 'http.response.start',
'status': 200, 'status': 200,
'headers': ( 'headers': encode_headers({
('Content-Type', 'text/html; charset=UTF-8'), 'Content-Type': 'text/html; charset=UTF-8',
('Etag', f'W/{digest}'), 'Etag': f'W/{digest}',
('Cache-Control', 'no-cache'), 'Cache-Control': 'no-cache',
) })
}) })
await send({ await send({
'type': 'http.response.body', 'type': 'http.response.body',
@@ -300,14 +321,14 @@ class Server:
}) })
@staticmethod @staticmethod
async def not_modified(send, digest: str, cache_control=('Cache-Control', 'no-cache')) -> []: async def not_modified(send, digest: str, cache_control: str ='no-cache') -> []:
await send({ await send({
'type': 'http.response.start', 'type': 'http.response.start',
'status': 304, 'status': 304,
'headers': ( 'headers': encode_headers({
(b'Etag', f'W/{digest}'.encode()), 'Etag': f'W/{digest}',
cache_control 'Cache-Control': cache_control
) })
}) })
await send({ await send({
'type': 'http.response.body', 'type': 'http.response.body',
@@ -350,3 +371,6 @@ class Server:
async for entry in await ls(file_filter): async for entry in await ls(file_filter):
result += '<li><a href="' + entry + '"/>' + entry + '</li>' result += '<li><a href="' + entry + '"/>' + entry + '</li>'
return result return result
async def stop(self):
await self.file_watcher.stop()

View File

@@ -1,21 +1,21 @@
function req(first) {
function req(first, previousETag) {
const minInterval = 2000;
const start = new Date().getTime(); const start = new Date().getTime();
const xmlhttp = new XMLHttpRequest(); const xmlhttp = new XMLHttpRequest();
xmlhttp.onload = function() { xmlhttp.onload = function() {
if (xmlhttp.status == 200) { const eTag = xmlhttp.getResponseHeader("Etag")
if (xmlhttp.status == 200 && eTag !== previousETag) {
document.querySelector("article.markdown-body").innerHTML = xmlhttp.responseText; document.querySelector("article.markdown-body").innerHTML = xmlhttp.responseText;
} else if(xmlhttp.status == 304) {
} else {
console.log(xmlhttp.status, xmlhttp.statusText);
} }
const nextCall = Math.min(1000, Math.max(0, 1000 - (new Date().getTime() - start))); const nextCall = Math.min(minInterval, Math.max(0, minInterval - (new Date().getTime() - start)));
setTimeout(req, nextCall, false); setTimeout(req, nextCall, false, eTag);
}; };
xmlhttp.onerror = function() { xmlhttp.onerror = function() {
console.log(xmlhttp.status, xmlhttp.statusText); console.log(xmlhttp.status, xmlhttp.statusText);
setTimeout(req, 1000, false); setTimeout(req, minInterval, false, previousETag);
}; };
xmlhttp.open("GET", location.pathname + "?reload", true); xmlhttp.open("GET", location.pathname + "?reload", true);
xmlhttp.send(); xmlhttp.send();
} }
req(true); req(true, null);