|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +__all__ = ("include_js", "include_css") |
| 4 | + |
| 5 | +import glob |
| 6 | +import hashlib |
| 7 | +import os |
| 8 | +import shutil |
| 9 | +import tempfile |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +# TODO: maybe these include_*() functions should actually live in htmltools? |
| 13 | +from htmltools import HTMLDependency, Tag, TagAttrValue, tags |
| 14 | + |
| 15 | +from .._docstring import add_example |
| 16 | +from .._typing_extensions import Literal |
| 17 | + |
| 18 | +# TODO: it's bummer that, when method="link_files" and path is in the same directory |
| 19 | +# as the app, the app's source will be included. Should we just not copy .py/.r files? |
| 20 | + |
| 21 | + |
| 22 | +@add_example() |
| 23 | +def include_js( |
| 24 | + path: Path | str, |
| 25 | + *, |
| 26 | + method: Literal["link", "link_files", "inline"] = "link", |
| 27 | + **kwargs: TagAttrValue, |
| 28 | +) -> Tag: |
| 29 | + """ |
| 30 | + Include a JavaScript file |
| 31 | +
|
| 32 | + Parameters |
| 33 | + ---------- |
| 34 | + path |
| 35 | + A path to a JS file. |
| 36 | + method |
| 37 | + One of the following: |
| 38 | + * ``"link"``: Link to the JS file via a :func:`~ui.tags.script` tag. This |
| 39 | + method is generally preferrable to ``"inline"`` since it allows the browser |
| 40 | + to cache the file. |
| 41 | + * ``"link_files"``: Same as ``"link"``, but also allow for the JS file to |
| 42 | + request other files within ``path``'s immediate parent directory (e.g., |
| 43 | + ``import()` another file, if it is loaded with `type="module"`). Note that this isn't the default |
| 44 | + behavior because you should **be careful not to include files in the same |
| 45 | + directory as ``path`` that contain sensitive information**. A good general |
| 46 | + rule of thumb to follow is to have ``path`` be located in a subdirectory of |
| 47 | + the app directory. For example, if the app's source is located at |
| 48 | + ``/app/app.py``, then ``path`` should be somewhere like |
| 49 | + ``/app/js/custom.js`` (and all the other relevant accompanying 'safe' files |
| 50 | + should be located under ``/app/js/``). |
| 51 | + * ``"inline"``: Inline the JS file contents within a :func:`~ui.tags.script` |
| 52 | + tag. |
| 53 | + **kwargs |
| 54 | + Attributes which are passed on to `~ui.tags.script` |
| 55 | +
|
| 56 | +
|
| 57 | + Returns |
| 58 | + ------- |
| 59 | + : |
| 60 | + A :func:`~ui.tags.script` tag. |
| 61 | +
|
| 62 | + Note |
| 63 | + ---- |
| 64 | + This places a :func:`~ui.tags.script` tag in the :func:`~ui.tags.body` of the |
| 65 | + document. If instead, you want to place the tag in the :func:`~ui.tags.head` of the |
| 66 | + document, you can wrap it in ``head_content`` (in this case, just make sure you're |
| 67 | + aware that the DOM probably won't be ready when the script is executed). |
| 68 | +
|
| 69 | + .. code-block:: python |
| 70 | +
|
| 71 | + ui.fluidPage( |
| 72 | + head_content(ui.include_js("custom.js")), |
| 73 | + ) |
| 74 | +
|
| 75 | + # Alternately you can inline Javscript by changing the method. |
| 76 | + ui.fluidPage( |
| 77 | + head_content(ui.include_js("custom.js", method = "inline")), |
| 78 | + ) |
| 79 | +
|
| 80 | + See Also |
| 81 | + -------- |
| 82 | + ~ui.tags.script |
| 83 | + ~include_css |
| 84 | + """ |
| 85 | + file_path = check_path(path) |
| 86 | + |
| 87 | + if method == "inline": |
| 88 | + return tags.script(read_utf8(file_path), **kwargs) |
| 89 | + |
| 90 | + include_files = method == "link_files" |
| 91 | + path_dest, hash = maybe_copy_files(file_path, include_files) |
| 92 | + |
| 93 | + dep, src = create_include_dependency("include-js-" + hash, path_dest, include_files) |
| 94 | + |
| 95 | + return tags.script(dep, src=src, **kwargs) |
| 96 | + |
| 97 | + |
| 98 | +@add_example() |
| 99 | +def include_css( |
| 100 | + path: str, *, method: Literal["link", "link_files", "inline"] = "link" |
| 101 | +) -> Tag: |
| 102 | + """ |
| 103 | + Include a CSS file |
| 104 | +
|
| 105 | + Parameters |
| 106 | + ---------- |
| 107 | + path |
| 108 | + A path to a CSS file. |
| 109 | + method |
| 110 | + One of the following: |
| 111 | + * ``"link"``: Link to the CSS file via a :func:`~ui.tags.link` tag. This |
| 112 | + method is generally preferrable to ``"inline"`` since it allows the browser |
| 113 | + to cache the file. |
| 114 | + * ``"link_files"``: Same as ``"link"``, but also allow for the CSS file to |
| 115 | + request other files within ``path``'s immediate parent directory (e.g., |
| 116 | + ``@import()`` another file). Note that this isn't the default behavior |
| 117 | + because you should **be careful not to include files in the same directory |
| 118 | + as ``path`` that contain sensitive information**. A good general rule of |
| 119 | + thumb to follow is to have ``path`` be located in a subdirectory of the app |
| 120 | + directory. For example, if the app's source is located at ``/app/app.py``, |
| 121 | + then ``path`` should be somewhere like ``/app/css/custom.css`` (and all the |
| 122 | + other relevant accompanying 'safe' files should be located under |
| 123 | + ``/app/css/``). |
| 124 | + * ``"inline"``: Inline the CSS file contents within a :func:`~ui.tags.style` |
| 125 | + tag. |
| 126 | +
|
| 127 | +
|
| 128 | + Returns |
| 129 | + ------- |
| 130 | + : |
| 131 | +
|
| 132 | + If ``method="inline"``, returns a :func:`~ui.tags.style` tag; otherwise, returns a |
| 133 | + :func:`~ui.tags.link` tag. |
| 134 | +
|
| 135 | + Note |
| 136 | + ---- |
| 137 | + By default this places a :func:`~ui.tags.link` (or :func:`~ui.tags.style`) tag in |
| 138 | + the :func:`~ui.tags.body` of the document, which isn't optimal for performance, and |
| 139 | + may result in a Flash of Unstyled Content (FOUC). To instead place the CSS in the |
| 140 | + :func:`~ui.tags.head` of the document, you can wrap it in ``head_content``: |
| 141 | +
|
| 142 | + .. code-block:: python |
| 143 | +
|
| 144 | + from htmltools import head_content from shiny import ui |
| 145 | +
|
| 146 | + ui.fluidPage( |
| 147 | + head_content(ui.include_css("custom.css")), |
| 148 | +
|
| 149 | + # You can also inline css by passing a dictionary with a `style` element. |
| 150 | + ui.div( |
| 151 | + {"style": "font-weight: bold;"}, |
| 152 | + ui.p("Some text!"), |
| 153 | + ) |
| 154 | + ) |
| 155 | +
|
| 156 | + See Also |
| 157 | + -------- |
| 158 | + ~ui.tags.style |
| 159 | + ~ui.tags.link |
| 160 | + ~include_js |
| 161 | + """ |
| 162 | + |
| 163 | + file_path = check_path(path) |
| 164 | + if method == "inline": |
| 165 | + return tags.style(read_utf8(file_path), type="text/css") |
| 166 | + |
| 167 | + include_files = method == "link_files" |
| 168 | + path_dest, hash = maybe_copy_files(file_path, include_files) |
| 169 | + |
| 170 | + dep, src = create_include_dependency( |
| 171 | + "include-css-" + hash, path_dest, include_files |
| 172 | + ) |
| 173 | + |
| 174 | + return tags.link(dep, href=src, rel="stylesheet") |
| 175 | + |
| 176 | + |
| 177 | +# --------------------------------------------------------------------------- |
| 178 | +# Include helpers |
| 179 | +# --------------------------------------------------------------------------- |
| 180 | + |
| 181 | + |
| 182 | +def check_path(path: Path | str) -> Path: |
| 183 | + path = Path(path) |
| 184 | + if not path.exists(): |
| 185 | + err = f""" |
| 186 | + {path.absolute()} does not exist. |
| 187 | + Files are typically placed in the app directory and refered to with 'Path(__file__) / {path.name}' |
| 188 | + """ |
| 189 | + raise RuntimeError(err) |
| 190 | + return path |
| 191 | + |
| 192 | + |
| 193 | +def create_include_dependency( |
| 194 | + name: str, path: str, include_files: bool |
| 195 | +) -> tuple[HTMLDependency, str]: |
| 196 | + dep = HTMLDependency( |
| 197 | + name, |
| 198 | + DEFAULT_VERSION, |
| 199 | + source={"subdir": os.path.dirname(path)}, |
| 200 | + all_files=include_files, |
| 201 | + ) |
| 202 | + |
| 203 | + # source_path_map() tells us where the source subdir is mapped to on the client |
| 204 | + # (i.e., session._register_web_dependency() uses the same thing to determine where |
| 205 | + # to mount the subdir, but we can't assume an active session at this point). |
| 206 | + src = os.path.join(dep.source_path_map()["href"], os.path.basename(path)) |
| 207 | + |
| 208 | + return dep, src |
| 209 | + |
| 210 | + |
| 211 | +def maybe_copy_files(path: Path | str, include_files: bool) -> tuple[str, str]: |
| 212 | + hash = get_hash(path, include_files) |
| 213 | + |
| 214 | + # To avoid unnecessary work when the same file is included multiple times, |
| 215 | + # use a directory scoped by a hash of the file. |
| 216 | + tmpdir = os.path.join(tempfile.gettempdir(), "shiny_include_files", hash) |
| 217 | + path_dest = os.path.join(tmpdir, os.path.basename(path)) |
| 218 | + |
| 219 | + # Since the hash/tmpdir should represent all the files in the path's directory, |
| 220 | + # we can simply return here |
| 221 | + if os.path.exists(path_dest): |
| 222 | + return path_dest, hash |
| 223 | + |
| 224 | + # Otherwise, make sure we have a clean slate |
| 225 | + if os.path.exists(tmpdir): |
| 226 | + shutil.rmtree(tmpdir) |
| 227 | + |
| 228 | + if include_files: |
| 229 | + shutil.copytree(os.path.dirname(path), tmpdir) |
| 230 | + else: |
| 231 | + os.makedirs(tmpdir, exist_ok=True) |
| 232 | + shutil.copy(path, path_dest) |
| 233 | + |
| 234 | + return path_dest, hash |
| 235 | + |
| 236 | + |
| 237 | +def get_hash(path: Path | str, include_files: bool) -> str: |
| 238 | + if include_files: |
| 239 | + key = get_file_key(path) |
| 240 | + else: |
| 241 | + dir = os.path.dirname(path) |
| 242 | + files = glob.iglob(os.path.join(dir, "**"), recursive=True) |
| 243 | + key = "\n".join([get_file_key(x) for x in files]) |
| 244 | + return hash_deterministic(key) |
| 245 | + |
| 246 | + |
| 247 | +def get_file_key(path: Path | str) -> str: |
| 248 | + path = Path(path) |
| 249 | + return str(path) + "-" + str(path.stat().st_mtime) |
| 250 | + |
| 251 | + |
| 252 | +def hash_deterministic(s: str) -> str: |
| 253 | + """ |
| 254 | + Returns a deterministic hash of the given string. |
| 255 | + """ |
| 256 | + return hashlib.sha1(s.encode("utf-8")).hexdigest() |
| 257 | + |
| 258 | + |
| 259 | +def read_utf8(path: Path | str) -> str: |
| 260 | + with open(path, "r", encoding="utf-8") as f: |
| 261 | + return f.read() |
| 262 | + |
| 263 | + |
| 264 | +DEFAULT_VERSION = "0.0" |
0 commit comments