Skip to content

Commit f4f61a0

Browse files
authored
Merge pull request #554 from rstudio/aron-quarto-play
quarto: adjust manifest construction
2 parents 85cf1d5 + 4cad083 commit f4f61a0

File tree

6 files changed

+232
-58
lines changed

6 files changed

+232
-58
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
`starlette>=0.35.0`. When deploying to these servers, the starlette version
1818
is now automatically set to `starlette<0.35.0`.
1919

20+
### Fixed
21+
22+
- Quarto content is marked as a "site" only when there are multiple input
23+
files. (#552)
24+
25+
- Quarto content automatically ignores `name.html` and `name_files` when
26+
`name.md`, `name.ipynb`, `name.Rmd`, or `name.qmd` is an input. (#553)
27+
28+
- Patterns provided to `--exclude` allow NT-style paths on Windows. (#320)
29+
2030
### Removed
2131

2232
- Python 3.7 support.

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,15 @@ The following shows an example of an extra file taking precedence:
319319
rsconnect deploy dash --exclude “*.csv” dash-app/ important_data.csv
320320
```
321321

322+
The "`**`" glob pattern will recursively match all files and directories,
323+
while "`*`" only matches files. The "`**`" pattern is useful with complicated
324+
project hierarchies where enumerating the _included_ files is simpler than
325+
listing the _exclusions_.
326+
327+
```bash
328+
rsconnect deploy quarto . _quarto.yml index.qmd requirements.txt --exclude "**"
329+
```
330+
322331
Some directories are excluded by default, to prevent bundling and uploading files that are not needed or might interfere with the deployment process:
323332

324333
```

rsconnect/bundle.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,10 @@ def __init__(
9494
"version": quarto_inspection.get("quarto", {}).get("version", "99.9.9"),
9595
"engines": quarto_inspection.get("engines", []),
9696
}
97-
project_config = quarto_inspection.get("config", {}).get("project", {})
98-
render_targets = project_config.get("render", [])
99-
if len(render_targets):
100-
self.data["metadata"]["primary_rmd"] = render_targets[0]
101-
project_type = project_config.get("type", None)
102-
if project_type or len(render_targets) > 1:
97+
98+
files_data = quarto_inspection.get("files", {})
99+
files_input_data = files_data.get("input", [])
100+
if len(files_input_data) > 1:
103101
self.data["metadata"]["content_category"] = "site"
104102

105103
if environment:
@@ -325,12 +323,10 @@ def make_source_manifest(
325323
"version": quarto_inspection.get("quarto", {}).get("version", "99.9.9"),
326324
"engines": quarto_inspection.get("engines", []),
327325
}
328-
project_config = quarto_inspection.get("config", {}).get("project", {})
329-
render_targets = project_config.get("render", [])
330-
if len(render_targets):
331-
manifest["metadata"]["primary_rmd"] = render_targets[0]
332-
project_type = project_config.get("type", None)
333-
if project_type or len(render_targets) > 1:
326+
327+
files_data = quarto_inspection.get("files", {})
328+
files_input_data = files_data.get("input", [])
329+
if len(files_input_data) > 1:
334330
manifest["metadata"]["content_category"] = "site"
335331

336332
if environment:
@@ -1303,13 +1299,19 @@ def make_quarto_manifest(
13031299
output_dir = project_config.get("output-dir", None)
13041300
if output_dir:
13051301
excludes = excludes + [output_dir]
1306-
else:
1307-
render_targets = project_config.get("render", [])
1308-
for target in render_targets:
1309-
t, _ = splitext(target)
1310-
# TODO: Single-file inspect would give inspect.formats.html.pandoc.output-file
1311-
# For foo.qmd, we would get an output-file=foo.html, but foo_files is not available.
1312-
excludes = excludes + [t + ".html", t + "_files"]
1302+
1303+
files_data = quarto_inspection.get("files", {})
1304+
files_input_data = files_data.get("input", [])
1305+
# files.input is a list of absolute paths to input (rendered)
1306+
# files. Automatically ignore the most common derived files for
1307+
# those inputs.
1308+
#
1309+
# These files are ignored even when the project has an output
1310+
# directory, as Quarto may create these files while a render is
1311+
# in-flight.
1312+
for each in files_input_data:
1313+
t, _ = splitext(os.path.relpath(each, file_or_directory))
1314+
excludes = excludes + [t + ".html", t + "_files/**/*"]
13131315

13141316
# relevant files don't need to include requirements.txt file because it is
13151317
# always added to the manifest (as a buffer) from the environment contents

rsconnect/models.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Data models
33
"""
44

5-
import os
5+
import pathlib
66
import re
77

88
import fnmatch
@@ -163,6 +163,7 @@ class GlobMatcher(object):
163163
"""
164164

165165
def __init__(self, pattern):
166+
pattern = pathlib.PurePath(pattern).as_posix()
166167
if pattern.endswith("/**/*"):
167168
# Note: the index used here makes sure the pattern has a trailing
168169
# slash. We want that.
@@ -185,7 +186,8 @@ def _to_parts_list(pattern):
185186
:return: a list of pattern pieces and the index of the special '**' pattern.
186187
The index will be None if `**` is never found.
187188
"""
188-
parts = pattern.split(os.path.sep)
189+
# Incoming pattern is ALWAYS a Posix-style path.
190+
parts = pattern.split("/")
189191
depth_wildcard_index = None
190192
for index, name in enumerate(parts):
191193
if name == "**":
@@ -197,10 +199,12 @@ def _to_parts_list(pattern):
197199
return parts, depth_wildcard_index
198200

199201
def _match_with_starts_with(self, path):
202+
path = pathlib.PurePath(path).as_posix()
200203
return path.startswith(self._pattern)
201204

202205
def _match_with_list_parts(self, path):
203-
parts = path.split(os.path.sep)
206+
path = pathlib.PurePath(path).as_posix()
207+
parts = path.split("/")
204208

205209
def items_match(i1, i2):
206210
if i2 >= len(parts):

tests/test_bundle.py

Lines changed: 160 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,23 @@
4747
from .utils import get_dir, get_manifest_path
4848

4949

50+
def create_fake_quarto_rendered_output(target_dir, name):
51+
with open(join(target_dir, f"{name}.html"), "w") as fp:
52+
fp.write(f"<html><body>fake rendering: {name}</body></html>\n")
53+
files_dir = join(target_dir, f"{name}_files")
54+
os.mkdir(files_dir)
55+
with open(join(files_dir, "resource.js"), "w") as fp:
56+
fp.write("// fake resource.js\n")
57+
58+
5059
class TestBundle(TestCase):
5160
@staticmethod
5261
def python_version():
5362
return ".".join(map(str, sys.version_info[:3]))
5463

64+
def setUp(self):
65+
self.maxDiff = None
66+
5567
def test_to_bytes(self):
5668
self.assertEqual(to_bytes(b"abc123"), b"abc123")
5769
self.assertEqual(to_bytes(b"\xc3\xa5bc123"), b"\xc3\xa5bc123")
@@ -64,7 +76,6 @@ def test_to_bytes(self):
6476
self.assertEqual(to_bytes("åbc123"), b"\xc3\xa5bc123")
6577

6678
def test_make_notebook_source_bundle1(self):
67-
self.maxDiff = 5000
6879
directory = get_dir("pip1")
6980
nb_path = join(directory, "dummy.ipynb")
7081

@@ -135,7 +146,6 @@ def test_make_notebook_source_bundle1(self):
135146
)
136147

137148
def test_make_notebook_source_bundle2(self):
138-
self.maxDiff = 5000
139149
directory = get_dir("pip2")
140150
nb_path = join(directory, "dummy.ipynb")
141151

@@ -221,24 +231,27 @@ def test_make_notebook_source_bundle2(self):
221231
},
222232
)
223233

224-
def test_make_quarto_source_bundle_from_project(self):
234+
def test_make_quarto_source_bundle_from_simple_project(self):
225235
temp_proj = tempfile.mkdtemp()
226236

227-
# add project files
228-
fp = open(join(temp_proj, "myquarto.qmd"), "w")
229-
fp.write("---\n")
230-
fp.write("title: myquarto\n")
231-
fp.write("jupyter: python3\n")
232-
fp.write("---\n\n")
233-
fp.write("```{python}\n")
234-
fp.write("1 + 1\n")
235-
fp.write("```\n")
236-
fp.close()
237-
238-
fp = open(join(temp_proj, "_quarto.yml"), "w")
239-
fp.write("project:\n")
240-
fp.write(' title: "myquarto"\n')
241-
fp.write("editor: visual\n")
237+
# This is a simple project; it has a _quarto.yml and one Markdown file.
238+
with open(join(temp_proj, "_quarto.yml"), "w") as fp:
239+
fp.write("project:\n")
240+
fp.write(' title: "project with one rendered file"\n')
241+
242+
with open(join(temp_proj, "myquarto.qmd"), "w") as fp:
243+
fp.write("---\n")
244+
fp.write("title: myquarto\n")
245+
fp.write("jupyter: python3\n")
246+
fp.write("---\n\n")
247+
fp.write("```{python}\n")
248+
fp.write("1 + 1\n")
249+
fp.write("```\n")
250+
251+
# Create some files that should not make it into the manifest; they
252+
# should be automatically ignored because myquarto.qmd is a project
253+
# input file.
254+
create_fake_quarto_rendered_output(temp_proj, "myquarto")
242255

243256
environment = detect_environment(temp_proj)
244257

@@ -299,6 +312,122 @@ def test_make_quarto_source_bundle_from_project(self):
299312
},
300313
)
301314

315+
def test_make_quarto_source_bundle_from_complex_project(self):
316+
temp_proj = tempfile.mkdtemp()
317+
318+
# This is a complex project; it has a _quarto.yml and multiple
319+
# Markdown files.
320+
with open(join(temp_proj, "_quarto.yml"), "w") as fp:
321+
fp.write("project:\n")
322+
fp.write(" type: website\n")
323+
fp.write(' title: "myquarto"\n')
324+
325+
with open(join(temp_proj, "index.qmd"), "w") as fp:
326+
fp.write("---\n")
327+
fp.write("title: home\n")
328+
fp.write("jupyter: python3\n")
329+
fp.write("---\n\n")
330+
fp.write("```{python}\n")
331+
fp.write("1 + 1\n")
332+
fp.write("```\n")
333+
334+
with open(join(temp_proj, "about.qmd"), "w") as fp:
335+
fp.write("---\n")
336+
fp.write("title: about\n")
337+
fp.write("---\n\n")
338+
fp.write("math, math, math.\n")
339+
340+
# Create some files that should not make it into the manifest; they
341+
# should be automatically ignored because myquarto.qmd is a project
342+
# input file.
343+
#
344+
# Create files both in the current directory and beneath _site (the
345+
# implicit output-dir for websites).
346+
create_fake_quarto_rendered_output(temp_proj, "index")
347+
create_fake_quarto_rendered_output(temp_proj, "about")
348+
site_dir = join(temp_proj, "_site")
349+
os.mkdir(site_dir)
350+
create_fake_quarto_rendered_output(site_dir, "index")
351+
create_fake_quarto_rendered_output(site_dir, "about")
352+
353+
environment = detect_environment(temp_proj)
354+
355+
# mock the result of running of `quarto inspect <project_dir>`
356+
inspect = {
357+
"quarto": {"version": "1.3.433"},
358+
"dir": temp_proj,
359+
"engines": [
360+
"markdown",
361+
"jupyter",
362+
],
363+
"config": {
364+
"project": {
365+
"type": "website",
366+
"output-dir": "_site",
367+
},
368+
},
369+
"files": {
370+
"input": [
371+
temp_proj + "/index.qmd",
372+
temp_proj + "/about.qmd",
373+
],
374+
"resources": [],
375+
"config": [temp_proj + "/_quarto.yml"],
376+
"configResources": [],
377+
},
378+
}
379+
380+
with make_quarto_source_bundle(
381+
temp_proj, inspect, AppModes.STATIC_QUARTO, environment, [], [], None
382+
) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar:
383+
names = sorted(tar.getnames())
384+
self.assertEqual(
385+
names,
386+
[
387+
"_quarto.yml",
388+
"about.qmd",
389+
"index.qmd",
390+
"manifest.json",
391+
"requirements.txt",
392+
],
393+
)
394+
395+
reqs = tar.extractfile("requirements.txt").read()
396+
self.assertIsNotNone(reqs)
397+
398+
manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8"))
399+
400+
# noinspection SpellCheckingInspection
401+
self.assertEqual(
402+
manifest,
403+
{
404+
"version": 1,
405+
"locale": mock.ANY,
406+
"metadata": {
407+
"appmode": "quarto-static",
408+
"content_category": "site",
409+
},
410+
"python": {
411+
"version": self.python_version(),
412+
"package_manager": {
413+
"name": "pip",
414+
"package_file": "requirements.txt",
415+
"version": mock.ANY,
416+
},
417+
},
418+
"quarto": {
419+
"engines": ["markdown", "jupyter"],
420+
"version": mock.ANY,
421+
},
422+
"files": {
423+
"_quarto.yml": {"checksum": mock.ANY},
424+
"index.qmd": {"checksum": mock.ANY},
425+
"about.qmd": {"checksum": mock.ANY},
426+
"requirements.txt": {"checksum": mock.ANY},
427+
},
428+
},
429+
)
430+
302431
def test_make_quarto_source_bundle_from_project_with_requirements(self):
303432
temp_proj = tempfile.mkdtemp()
304433

@@ -385,14 +514,19 @@ def test_make_quarto_source_bundle_from_project_with_requirements(self):
385514
def test_make_quarto_source_bundle_from_file(self):
386515
temp_proj = tempfile.mkdtemp()
387516

517+
filename = join(temp_proj, "myquarto.qmd")
388518
# add single qmd file with markdown engine
389-
fp = open(join(temp_proj, "myquarto.qmd"), "w")
390-
fp.write("---\n")
391-
fp.write("title: myquarto\n")
392-
fp.write("engine: markdown\n")
393-
fp.write("---\n\n")
394-
fp.write("### This is a test\n")
395-
fp.close()
519+
with open(filename, "w") as fp:
520+
fp.write("---\n")
521+
fp.write("title: myquarto\n")
522+
fp.write("engine: markdown\n")
523+
fp.write("---\n\n")
524+
fp.write("### This is a test\n")
525+
526+
# Create some files that should not make it into the manifest; they
527+
# should be automatically ignored because myquarto.qmd is the input
528+
# file.
529+
create_fake_quarto_rendered_output(temp_proj, "myquarto")
396530

397531
# mock the result of running of `quarto inspect <qmd_file>`
398532
inspect = {
@@ -401,7 +535,7 @@ def test_make_quarto_source_bundle_from_file(self):
401535
}
402536

403537
with make_quarto_source_bundle(
404-
temp_proj, inspect, AppModes.STATIC_QUARTO, None, [], [], None
538+
filename, inspect, AppModes.STATIC_QUARTO, None, [], [], None
405539
) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar:
406540
names = sorted(tar.getnames())
407541
self.assertEqual(
@@ -469,7 +603,6 @@ def test_html_bundle2(self):
469603
self.do_test_html_bundle(get_dir("pip2"))
470604

471605
def do_test_html_bundle(self, directory):
472-
self.maxDiff = 5000
473606
nb_path = join(directory, "dummy.ipynb")
474607

475608
bundle = make_notebook_html_bundle(
@@ -521,7 +654,6 @@ def test_keep_manifest_specified_file(self):
521654
self.assertFalse(keep_manifest_specified_file(".Rproj.user/bogus.file"))
522655

523656
def test_manifest_bundle(self):
524-
self.maxDiff = 5000
525657
# noinspection SpellCheckingInspection
526658
manifest_path = join(dirname(__file__), "testdata", "R", "shinyapp", "manifest.json")
527659

0 commit comments

Comments
 (0)