diff --git a/CHANGELOG.md b/CHANGELOG.md index f710cf6d6..0c452e703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `include_js()` and `include_css()` now correctly handle file permissions in multi-user settings. (#2061) +* Temporary directories/files created for HTML dependencies will be cleaned up on App object deletion (#2079) + ### Deprecations * `ui.update_navs()` has been deprecated in favor of `ui.update_navset()`. (#2047) diff --git a/shiny/_app.py b/shiny/_app.py index 0de886a69..62d7f8037 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -3,6 +3,8 @@ import copy import os import secrets +import shutil +import tempfile from contextlib import AsyncExitStack, asynccontextmanager from inspect import signature from pathlib import Path @@ -214,6 +216,35 @@ def __init__( cast("Tag | TagList", ui), lib_prefix=self.lib_prefix ) + def __del__(self) -> None: + deps = self._registered_dependencies.values() + self._cleanup_temp_source_dirs(list(deps)) + + @staticmethod + def _cleanup_temp_source_dirs(deps: list[HTMLDependency]) -> None: + # include_css()/include_js() create temporary directories to hold files that + # persist across user sessions, but also need to be cleaned up at some point, + # and Python (unlike R) does not cleanup tempdirs on process exit. So, our next + # best option is to clean them up when the App object is deleted. It's not + # perfect (the App object might be deleted while the process is still running, + # and there might be multiple App objects using the same UI). However, it still + # seems worth doing since that is such a hypothetical edge case. More generally, + # if _any_ HTMLDependency with a source directory that is a _subdirectory_ of + # the (system-wide) temp directory, we should remove it. + current_temp_dir = os.path.realpath(tempfile.gettempdir()) + for dep in deps: + src = dep.source.get("subdir") if dep.source else None + if not src: + continue + src = os.path.realpath(src) + if not os.path.exists(src): + continue + if src == current_temp_dir: + continue + common = os.path.commonprefix([src, current_temp_dir]) + if common == current_temp_dir: + shutil.rmtree(src) + def init_starlette_app(self) -> starlette.applications.Starlette: routes: list[starlette.routing.BaseRoute] = [ starlette.routing.WebSocketRoute("/websocket/", self._on_connect_cb),