Skip to content

Commit be137b0

Browse files
authored
Merge pull request #127 from rstudio/include-helpers
2 parents 913bf75 + e325acb commit be137b0

File tree

6 files changed

+302
-0
lines changed

6 files changed

+302
-0
lines changed

shiny/examples/include_css/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pathlib import Path
2+
3+
from shiny import *
4+
5+
css_file = Path(__file__).parent / "css" / "styles.css"
6+
7+
app_ui = ui.page_fluid(
8+
"Almost before we knew it, we had left the ground!!!",
9+
ui.include_css(css_file),
10+
ui.div(
11+
# Style individual elements with an attribute dictionary.
12+
{"style": "font-weight: bold"},
13+
ui.p("Bold text"),
14+
),
15+
)
16+
17+
app = App(app_ui, None)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
body {
2+
font-size: 3rem;
3+
background-color: pink
4+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from pathlib import Path
2+
3+
from shiny import *
4+
5+
js_file = Path(__file__).parent / "js" / "app.js"
6+
7+
app_ui = ui.page_fluid(
8+
"If you see this page before 'OK'-ing the alert box, something went wrong",
9+
ui.include_js(js_file),
10+
)
11+
12+
13+
app = App(app_ui, None)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
alert("If you're seeing this, the javascript file was included successfully.");

shiny/ui/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from ._download_button import download_button, download_link
2020
from ._plot_output_opts import brush_opts, click_opts, dblclick_opts, hover_opts
21+
from ._include_helpers import include_css, include_js
2122
from ._input_action_button import input_action_button, input_action_link
2223
from ._input_check_radio import (
2324
input_checkbox,
@@ -124,6 +125,8 @@
124125
"click_opts",
125126
"dblclick_opts",
126127
"hover_opts",
128+
"include_css",
129+
"include_js",
127130
"input_action_button",
128131
"input_action_link",
129132
"input_checkbox",

shiny/ui/_include_helpers.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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

Comments
 (0)