diff --git a/poetry.lock b/poetry.lock index 0e21196..e1e1b22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "attrs" @@ -757,6 +757,47 @@ brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "watchdog" +version = "4.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + [[package]] name = "webdriver-manager" version = "4.0.1" @@ -790,4 +831,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "87fb57184f43f68f30e32d9bf2968118c1723e96dd00837c3cd1865795a5e16f" +content-hash = "6cbbf4edb665533fa69ce4acc3796c0562d4671eac1b161ae98d11b6c314a757" diff --git a/pyproject.toml b/pyproject.toml index 90fa21a..4cd45f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/stellapy/executor.py b/stellapy/executor.py index 89742e6..a6a5fe5 100644 --- a/stellapy/executor.py +++ b/stellapy/executor.py @@ -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: diff --git a/stellapy/reloader.py b/stellapy/reloader.py index 34ba641..e84e6ce 100644 --- a/stellapy/reloader.py +++ b/stellapy/reloader.py @@ -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] @@ -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 @@ -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": @@ -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: """ @@ -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() @@ -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 @@ -335,6 +305,8 @@ def stop(self): exception(e) finally: self._finished = True + self.observer.stop() + self.observer.join() def start(self) -> None: """ @@ -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() diff --git a/stellapy/walker.py b/stellapy/walker.py index 6800d6f..bf60aee 100644 --- a/stellapy/walker.py +++ b/stellapy/walker.py @@ -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 @@ -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: @@ -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()