Skip to content

Commit

Permalink
implement polling using watchdog
Browse files Browse the repository at this point in the history
  • Loading branch information
shravanasati committed May 21, 2024
1 parent ccb68a8 commit 4466b96
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 133 deletions.
45 changes: 43 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ rich = "^13.7.0"
ruamel-yaml = "^0.18.5"
gitignorefile = "^1.1.2"
jsonschema = "^4.20.0"
watchdog = "^4.0.0"


[build-system]
Expand Down
4 changes: 3 additions & 1 deletion stellapy/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ def build_command(script: Script):
return joined_command, True

else:
raise TypeError(f"invalid type of {script.command=}, {type(script.command)=}")
raise TypeError(
f"invalid type of {script.command=}, {type(script.command)=}"
)

def start(self):
try:
Expand Down
108 changes: 42 additions & 66 deletions stellapy/reloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from typing import Any, Callable, Generic, TypeVar

import helium
from watchdog.observers import Observer

from stellapy.configuration import Configuration
from stellapy.executor import Executor
from stellapy.logger import log
from stellapy.walker import get_file_content, walk

from stellapy.walker import PatternMatchingEventHandler

T = TypeVar("T")
ActionFunc = Callable[["Trigger"], None]
Expand Down Expand Up @@ -101,7 +101,14 @@ def __init__(
self.executor = Executor(self.script)
self.url = self.script.url
self.RELOAD_BROWSER = bool(self.url)
self.project_data = self.get_project_data()

# watchdog observer
self.observer = Observer()
self.observer.schedule(
PatternMatchingEventHandler(self.config.include_only, self._restart),
".",
recursive=True,
)

# trigger executor
self._finished = False # used by trigger thread to look for exits
Expand All @@ -125,43 +132,6 @@ def _trigger_executor(self):
self.trigger_queue.execute_remaining()
sleep(self.trigger_execution_interval)

def get_project_data(self) -> dict:
"""
Returns a dict with filenames mapped to their contents.
"""
project_data = {}
for f in walk(self.config.include_only, self.config.follow_symlinks):
project_data.update({f: get_file_content(f)})

return project_data

def detect_change(self) -> bool:
"""
Detects if a change has been done to the project. Also updates the project data if
new a change is detected.
"""
new_content = self.get_project_data()
if len(self.project_data.keys()) != len(new_content.keys()):
self.project_data = new_content
return True

try:
for k, v in self.project_data.items():
if new_content[k] != v:
self.project_data = new_content
return True

except KeyError:
self.project_data = new_content
return True

except Exception as e:
print("FATAL ERROR: This should never happen.")
exception(e)
self.stop()

return False

def start_browser(self):
browser = self.config.browser
if browser == "chrome":
Expand Down Expand Up @@ -240,32 +210,28 @@ def _browser_reload_error_handler(self, t: Trigger[timedelta], e: Exception):
)

def _restart(self):
if self.detect_change():
log(
"info",
"detected changes in the project, reloading server and browser",
log(
"info",
"detected changes in the project, reloading server and browser",
)
# cancel all prev triggers, because we got a new change
self.trigger_queue.cancel_all()
self.executor.re_execute()
if self.RELOAD_BROWSER:
self.trigger_queue.add(
Trigger[timedelta](
action=self._browser_reloader,
when=datetime.now() + self.browser_wait_delta,
error_handler=self._browser_reload_error_handler,
value=self.browser_wait_delta,
),
)
# cancel all prev triggers, because we got a new change
self.trigger_queue.cancel_all()
self.executor.re_execute()
if self.RELOAD_BROWSER:
self.trigger_queue.add(
Trigger[timedelta](
action=self._browser_reloader,
when=datetime.now() + self.browser_wait_delta,
error_handler=self._browser_reload_error_handler,
value=self.browser_wait_delta,
),
)

else:
sleep(self.poll_interval)

def restart(self) -> None:
if self.RELOAD_BROWSER:
self.start_browser()
while not self._finished:
self._restart()
# def restart(self) -> None:
# if self.RELOAD_BROWSER:
# self.start_browser()
# while not self._finished:
# self._restart()

def manual_input(self) -> None:
"""
Expand All @@ -276,6 +242,7 @@ def manual_input(self) -> None:
message = input().lower().strip()
except EOFError:
break

if message == "ex":
log("info", "stopping server")
self.stop()
Expand All @@ -302,7 +269,10 @@ def manual_input(self) -> None:
except Exception:
log("error", "unable to refresh browser window")
else:
log("stella", "no browser URL is configured, can't refresh browser window")
log(
"stella",
"no browser URL is configured, can't refresh browser window",
)

# ! too much black magic required to have configuration reloaded
# ! it's because stop_server calls os._exit and that stops the entire progam because there
Expand Down Expand Up @@ -335,6 +305,8 @@ def stop(self):
exception(e)
finally:
self._finished = True
self.observer.stop()
self.observer.join()

def start(self) -> None:
"""
Expand All @@ -360,4 +332,8 @@ def start(self) -> None:
input_thread = Thread(target=self.manual_input, daemon=True)
input_thread.start()
self.executor.start()
self.restart()
if self.RELOAD_BROWSER:
self.start_browser()

self.observer.start()
# self.restart()
100 changes: 36 additions & 64 deletions stellapy/walker.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import os
from logging import exception
from datetime import datetime, timedelta
from pathlib import Path
from typing import Iterable
from typing import Callable, Iterable

import gitignorefile

IGNORE_PATTERN = None
INCLUDE_PATTERN = None
from watchdog.events import FileSystemEvent, FileSystemEventHandler, EVENT_TYPE_CLOSED, EVENT_TYPE_OPENED


def get_ignore_include_patterns(include_only: Iterable[str] | None):
global IGNORE_PATTERN, INCLUDE_PATTERN
if IGNORE_PATTERN and INCLUDE_PATTERN:
# if they are already cached
return IGNORE_PATTERN, INCLUDE_PATTERN

ignore_filepath = find_ignore_file()
ignore_match = (
gitignorefile.parse(ignore_filepath) if ignore_filepath else lambda _: False
Expand All @@ -28,57 +20,37 @@ def get_ignore_include_patterns(include_only: Iterable[str] | None):
else lambda _: True
)

# compute patterns once and cache them
IGNORE_PATTERN = ignore_match
INCLUDE_PATTERN = include_match
return IGNORE_PATTERN, INCLUDE_PATTERN
return ignore_match, include_match


# todo use watchdog to track filesystem changes instead of polling
def walk(include_only: Iterable[str] | None, follow_symlinks: bool):
class PatternMatchingEventHandler(FileSystemEventHandler):
"""
The `walk` function recursively searches for all files in the project returns a list of
valid files.
Subclass of `watchdog.FileSystemEventHandler` which implements gitignore-style
pattern matching.
"""

try:
ignore_match, include_match = get_ignore_include_patterns(include_only)
# project_files = []
for root, _, files in os.walk(".", topdown=True, followlinks=follow_symlinks):
if ".git" in root or ignore_match(root):
continue

for file in files:
if ignore_match(root):
continue
if include_match(file):
yield os.path.join(root, file)
# project_files.append(os.path.join(root, file))

# return project_files

except Exception as e:
exception(e)
return []


def get_file_content(filepath: str) -> str:
"""
`get_file_content` returns the content of the file. Ignores binary files.
"""
try:
with open(filepath, encoding="utf-8") as f:
fc = f.read()

return fc

except UnicodeDecodeError:
# binary file, ignore
return ""

except Exception as e:
exception(e)
return ""
def __init__(self, include_only: Iterable[str] | None, callback: Callable[[], None]) -> None:
super().__init__()
self.ignore_match, self.include_match = get_ignore_include_patterns(include_only)
self.callback_fn = callback
self.last_event_time = datetime.now()

def on_any_event(self, event: FileSystemEvent) -> None:
# only respond to events after a certain threshold
if datetime.now() - self.last_event_time > timedelta(milliseconds=500):
super().on_any_event(event)
self.callback_fn()
self.last_event_time = datetime.now()

def dispatch(self, event: FileSystemEvent) -> None:
no_dispatch_conditions = {
self.ignore_match(event.src_path),
".git" in event.src_path,
event.event_type in (EVENT_TYPE_OPENED, EVENT_TYPE_CLOSED),
not self.include_match(event.src_path)
}
if any(no_dispatch_conditions):
return
return super().dispatch(event)


def find_ignore_file(base_dir: str | None = None) -> str | None:
Expand Down Expand Up @@ -120,9 +92,9 @@ def __find_file_recursively(filename: str, base_dir: str | None = None) -> str |
print(find_ignore_file())
print(find_config_file())
input()
for i in walk(["*.py"], False):
...
print(i)
# input()
# print(get_file_content(i))
# input()
# for i in walk(["*.py"], False):
# ...
# print(i)
# input()
# print(get_file_content(i))
# input()

0 comments on commit 4466b96

Please sign in to comment.