Skip to content

Commit e5bdc26

Browse files
committed
WIP - reorg core into multiple modules, now using starlette/uvicorn servers
1 parent 42797ce commit e5bdc26

File tree

9 files changed

+562
-347
lines changed

9 files changed

+562
-347
lines changed

jinja2html/__main__.py

Lines changed: 101 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
from rich.logging import RichHandler
2020
from watchfiles import awatch, Change
2121

22-
from .core import Context, is_css_js, WebsiteManager
23-
22+
from .build_context import Context
23+
from .website_manager import WebsiteManager
24+
from .web_ui import main_loop
25+
from .utils import is_css_js
2426

2527
_SESSIONS = defaultdict(list)
2628

@@ -29,108 +31,108 @@
2931
log = logging.getLogger(__name__)
3032

3133

32-
async def ws_handler(websocket: websockets.WebSocketServerProtocol) -> None:
33-
"""Handler managing an individual websocket's lifecycle, for use with `websockets.serve`
34+
# async def ws_handler(websocket: websockets.WebSocketServerProtocol) -> None:
35+
# """Handler managing an individual websocket's lifecycle, for use with `websockets.serve`
3436

35-
Args:
36-
websocket (websockets.WebSocketServerProtocol): The websocket object representing a new websocket connection.
37-
"""
38-
request_content = json.loads(await websocket.recv())
39-
log.debug("received message via websocket from a client: %s", request_content)
37+
# Args:
38+
# websocket (websockets.WebSocketServerProtocol): The websocket object representing a new websocket connection.
39+
# """
40+
# request_content = json.loads(await websocket.recv())
41+
# log.debug("received message via websocket from a client: %s", request_content)
4042

41-
if request_content.get("command") == "hello": # initial handshake
42-
await websocket.send('{"command": "hello", "protocols": ["http://livereload.com/protocols/official-7"], "serverName": "jinja2html"}')
43-
else:
44-
log.error("Bad liveserver handshake request from a client: %s", request_content)
45-
return
43+
# if request_content.get("command") == "hello": # initial handshake
44+
# await websocket.send('{"command": "hello", "protocols": ["http://livereload.com/protocols/official-7"], "serverName": "jinja2html"}')
45+
# else:
46+
# log.error("Bad liveserver handshake request from a client: %s", request_content)
47+
# return
4648

47-
request_content = json.loads(await websocket.recv())
49+
# request_content = json.loads(await websocket.recv())
4850

49-
# sample reply: {'command': 'info', 'plugins': {'less': {'disable': False, 'version': '1.0'}}, 'url': 'http://localhost:8000/ok.html'}
50-
if request_content.get("command") == "info":
51-
log.info("New websocket connection estasblished at: %s", request_content.get('url'))
52-
else:
53-
log.error("Something went wrong during response from handshake: %s", request_content)
54-
return
51+
# # sample reply: {'command': 'info', 'plugins': {'less': {'disable': False, 'version': '1.0'}}, 'url': 'http://localhost:8000/ok.html'}
52+
# if request_content.get("command") == "info":
53+
# log.info("New websocket connection estasblished at: %s", request_content.get('url'))
54+
# else:
55+
# log.error("Something went wrong during response from handshake: %s", request_content)
56+
# return
5557

56-
url_path = urlparse(request_content.get('url')).path.lstrip("/")
57-
_SESSIONS[url_path].append(websocket)
58+
# url_path = urlparse(request_content.get('url')).path.lstrip("/")
59+
# _SESSIONS[url_path].append(websocket)
5860

59-
log.debug("added a new websocket, websocket sessions are now %s: ", _SESSIONS)
61+
# log.debug("added a new websocket, websocket sessions are now %s: ", _SESSIONS)
6062

61-
try:
62-
async for message in websocket:
63-
log.info("received message from client: %s", message)
64-
except websockets.exceptions.WebSocketException as e:
65-
log.info("Closing websocket on '%s'", url_path) # TODO: specifics
66-
except asyncio.CancelledError:
67-
log.debug("received cancel in ws_handler. Doing nothing though.")
68-
69-
_SESSIONS[url_path].remove(websocket)
70-
log.debug("removed a dead websocket, websocket sessions are now %s: ", _SESSIONS)
71-
72-
73-
async def changed_files_handler(wm: WebsiteManager) -> None:
74-
"""Detects and handles updates to watched html/js/css files. Specifically, rebuild changed website files and notify websocket clients of changes.
75-
76-
Args:
77-
wm (WebsiteManager): The WebsiteManager to associate with this asyncio loop
78-
"""
79-
async for changes in awatch(wm.context.input_dir, watch_filter=wm.jinja_filter):
80-
l: set[Path] = set()
81-
build_all = notify_all = False
82-
83-
for change, p in changes:
84-
p = Path(p)
85-
if wm.context.is_template(p) or wm.context.is_config_json(p):
86-
l = wm.find_acceptable_files()
87-
build_all = True
88-
break
89-
elif change in (Change.added, Change.modified):
90-
l.add(p)
91-
if is_css_js(p):
92-
notify_all = True
93-
else:
94-
(wm.context.output_dir / wm.context.stub_of(p)).unlink(True)
95-
96-
wm.build_files(l)
97-
98-
if notify_all and not build_all:
99-
l = wm.find_acceptable_files()
100-
101-
for p in l:
102-
stub = str(wm.context.stub_of(p))
103-
message = f'{{"command": "reload", "path": "{stub}", "liveCSS": false}}'
104-
105-
if _SESSIONS.get(stub):
106-
await asyncio.wait([asyncio.create_task(socket.send(message)) for socket in _SESSIONS[stub]])
107-
108-
if p.name == "index.html" and _SESSIONS.get(""):
109-
await asyncio.wait([asyncio.create_task(socket.send(message)) for socket in _SESSIONS[""]])
110-
111-
112-
async def ws_server() -> None:
113-
"""Creates a websocket server and waits for it to be closed"""
114-
try:
115-
log.info("Serving websockets on http://localhost:%d", _WEBSOCKET_SERVER_PORT)
116-
async with websockets.serve(ws_handler, "localhost", _WEBSOCKET_SERVER_PORT):
117-
await asyncio.Future()
63+
# try:
64+
# async for message in websocket:
65+
# log.info("received message from client: %s", message)
66+
# except websockets.exceptions.WebSocketException as e:
67+
# log.info("Closing websocket on '%s'", url_path) # TODO: specifics
68+
# except asyncio.CancelledError:
69+
# log.debug("received cancel in ws_handler. Doing nothing though.")
11870

119-
except asyncio.CancelledError:
120-
log.debug("Received cancel in ws_server. Doing nothing though.")
71+
# _SESSIONS[url_path].remove(websocket)
72+
# log.debug("removed a dead websocket, websocket sessions are now %s: ", _SESSIONS)
12173

12274

123-
async def main_loop(wm: WebsiteManager) -> None:
124-
"""Entry point for asyncio operations in jinja2html
75+
# async def changed_files_handler(wm: WebsiteManager) -> None:
76+
# """Detects and handles updates to watched html/js/css files. Specifically, rebuild changed website files and notify websocket clients of changes.
12577

126-
Args:
127-
wm (WebsiteManager): The WebsiteManager to associate with this asyncio loop
128-
"""
129-
try:
130-
log.info("Setting up websocket server and process queue...")
131-
await asyncio.gather(ws_server(), changed_files_handler(wm))
132-
except asyncio.CancelledError:
133-
log.debug("Received cancel in wss_manager. Doing nothing though.")
78+
# Args:
79+
# wm (WebsiteManager): The WebsiteManager to associate with this asyncio loop
80+
# """
81+
# async for changes in awatch(wm.context.input_dir, watch_filter=wm.jinja_filter):
82+
# l: set[Path] = set()
83+
# build_all = notify_all = False
84+
85+
# for change, p in changes:
86+
# p = Path(p)
87+
# if wm.context.is_template(p) or wm.context.is_config_json(p):
88+
# l = wm.find_acceptable_files()
89+
# build_all = True
90+
# break
91+
# elif change in (Change.added, Change.modified):
92+
# l.add(p)
93+
# if is_css_js(p):
94+
# notify_all = True
95+
# else:
96+
# (wm.context.output_dir / wm.context.stub_of(p)).unlink(True)
97+
98+
# wm.build_files(l)
99+
100+
# if notify_all and not build_all:
101+
# l = wm.find_acceptable_files()
102+
103+
# for p in l:
104+
# stub = str(wm.context.stub_of(p))
105+
# message = f'{{"command": "reload", "path": "{stub}", "liveCSS": false}}'
106+
107+
# if _SESSIONS.get(stub):
108+
# await asyncio.wait([asyncio.create_task(socket.send(message)) for socket in _SESSIONS[stub]])
109+
110+
# if p.name == "index.html" and _SESSIONS.get(""):
111+
# await asyncio.wait([asyncio.create_task(socket.send(message)) for socket in _SESSIONS[""]])
112+
113+
114+
# async def ws_server() -> None:
115+
# """Creates a websocket server and waits for it to be closed"""
116+
# try:
117+
# log.info("Serving websockets on http://localhost:%d", _WEBSOCKET_SERVER_PORT)
118+
# async with websockets.serve(ws_handler, "localhost", _WEBSOCKET_SERVER_PORT):
119+
# await asyncio.Future()
120+
121+
# except asyncio.CancelledError:
122+
# log.debug("Received cancel in ws_server. Doing nothing though.")
123+
124+
125+
# async def main_loop(wm: WebsiteManager) -> None:
126+
# """Entry point for asyncio operations in jinja2html
127+
128+
# Args:
129+
# wm (WebsiteManager): The WebsiteManager to associate with this asyncio loop
130+
# """
131+
# try:
132+
# log.info("Setting up websocket server and process queue...")
133+
# await asyncio.gather(ws_server(), changed_files_handler(wm))
134+
# except asyncio.CancelledError:
135+
# log.debug("Received cancel in wss_manager. Doing nothing though.")
134136

135137

136138
def _main() -> None:
@@ -154,17 +156,18 @@ def _main() -> None:
154156
log.addHandler(RichHandler(rich_tracebacks=True))
155157
log.setLevel(logging.INFO)
156158

157-
socketserver.TCPServer.allow_reuse_address = True # ward off OSErrors
158-
Thread(target=(httpd := socketserver.TCPServer(("localhost", args.p), partial(SimpleHTTPRequestHandler, directory=str(c.output_dir)))).serve_forever).start()
159+
# socketserver.TCPServer.allow_reuse_address = True # ward off OSErrors
160+
# Thread(target=(httpd := socketserver.TCPServer(("localhost", args.p), partial(SimpleHTTPRequestHandler, directory=str(c.output_dir)))).serve_forever).start()
159161

160-
open_new_tab(web_url := f"http://{httpd.server_address[0]}:{httpd.server_address[1]}")
161-
log.info("Serving website on '%s' and watching '%s' for html/js/css changes", web_url, c.input_dir)
162+
# open_new_tab(web_url := f"http://{httpd.server_address[0]}:{httpd.server_address[1]}")
163+
# open_new_tab(web_url := "http://localhost:8000")
164+
# log.info("Serving website on '%s' and watching '%s' for html/js/css changes", web_url, c.input_dir)
162165

163166
try:
164167
asyncio.run(main_loop(wm))
165168
except KeyboardInterrupt:
166169
print()
167-
httpd.shutdown()
170+
# httpd.shutdown()
168171
log.warning("Keyboard interrupt - bye")
169172

170173

jinja2html/build_context.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""the central build context and configuration options"""
2+
3+
import logging
4+
import re
5+
import shutil
6+
7+
from pathlib import Path
8+
9+
from jinja2 import Environment, FileSystemLoader
10+
11+
from .utils import is_html
12+
13+
14+
log = logging.getLogger(__name__)
15+
16+
17+
class Context:
18+
"""Collects shared configuration and simple methods for determining which files/directories to watch. There should only be one instance of this during the program's lifeycle."""
19+
20+
_FILE_PATTERN = re.compile(r"[^.].+\.(html|htm|css|js)", re.IGNORECASE)
21+
22+
def __init__(self, input_dir: Path = Path("."), output_dir: Path = Path("out"), template_dir: str = "templates", ignore_list: set[Path] = set(), dev_mode: bool = False) -> None:
23+
"""Initializer, creates a new `Context`. For best results, all `Path` type arguments should be absolute (this is automatically done in the initializer, but if you want to change the properties after initializing, make sure you do this).
24+
25+
Args:
26+
input_dir (Path, optional): The directory to watch for changes. Defaults to Path(".").
27+
output_dir (Path, optional): The directory to save generated files. Defaults to Path("out").
28+
template_dir (str, optional): The directory containing jinja2 mixin-type templates. If it exists, this is the name of a folder under `input_dir`. Defaults to "templates".
29+
ignore_list (set[Path], optional): The set of directories to ignore (will not be watched, even if `input_dir` is a parent folder). Defaults to set().
30+
dev_mode (bool, optional): Flag which turns on development mode (i.e. livereload server). Defaults to False.
31+
"""
32+
self.input_dir: Path = input_dir.resolve()
33+
self.output_dir: Path = output_dir.resolve()
34+
self.template_dir: Path = self.input_dir / template_dir
35+
self.dev_mode: bool = dev_mode
36+
37+
self.ignore_list: set[Path] = {p.resolve() for p in ignore_list} if ignore_list else ignore_list
38+
self.ignore_list.add(self.output_dir)
39+
40+
self.config_json = (self.input_dir / "config.json").resolve()
41+
self.t_env = Environment(loader=FileSystemLoader(self.input_dir))
42+
43+
def clean(self) -> None:
44+
"""Delete the output directory and regenerate all directories used by jinja2html."""
45+
# shutil.rmtree(self.output_dir, ignore_errors=True)
46+
47+
self.input_dir.mkdir(parents=True, exist_ok=True)
48+
self.template_dir.mkdir(parents=True, exist_ok=True)
49+
self.output_dir.mkdir(parents=True, exist_ok=True)
50+
51+
def stub_of(self, f: Path) -> Path:
52+
"""Convenience method, gets the path stub of `f` relative to `self.input_dir`. Useful for determining the path of the file in the output directory (`self.output_dir`).
53+
54+
Args:
55+
f (Path): The file `Path` to get an output stub for.
56+
57+
Returns:
58+
Path: The output path of `f` relative to `self.input_dir`.
59+
"""
60+
return f.relative_to(self.input_dir)
61+
62+
def is_template(self, f: Path) -> bool:
63+
"""Convienience method, determines whether a file is a template (i.e. in the `self.template_dir` directory)
64+
65+
Args:
66+
f (Path): The file to check. Use an absolute `Path` for best results.
67+
68+
Returns:
69+
bool: `True` if `f` is a template in the `self.template_dir` directory.
70+
"""
71+
return self.template_dir in f.parents and is_html(f)
72+
73+
def is_config_json(self, f: Path) -> bool:
74+
"""Convienience method, determines whether `f` is the `config.json` file.
75+
76+
Args:
77+
f (Path): The file to check. Use an absolute `Path` for best results.
78+
79+
Returns:
80+
bool: `True` if `f` is the `config.json` file.
81+
"""
82+
return f == self.config_json
83+
84+
def should_watch_file(self, p: str) -> bool:
85+
"""Determines whether a file should be watched.
86+
87+
Args:
88+
entry (str): The path to the file to check. Must be a full path.
89+
90+
Returns:
91+
bool: `True` if the file should be watched.
92+
"""
93+
return Context._FILE_PATTERN.match(p) or self.is_config_json(Path(p))

0 commit comments

Comments
 (0)