Skip to content

Commit 5b1ea3c

Browse files
authored
Merge pull request #362 from rstudio/bcwu-refactor-deploy-html
refactor deploy html
2 parents c1f163e + 07d182c commit 5b1ea3c

File tree

13 files changed

+623
-78
lines changed

13 files changed

+623
-78
lines changed

CHANGELOG.md

+65
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,71 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- Added `deploy voila` command to deploy Jupyter Voila notebooks.
11+
12+
### Changed
13+
- `deploy html` was refactored. Its behavior is described below.
14+
15+
### deploy html
16+
- specifying a directory in the path will result in that entire directory*, subdirectories, and sub contents included in the deploy bundle
17+
- the entire directory is included whether or not an entrypoint was supplied
18+
19+
20+
21+
e.g.
22+
using the following directory,
23+
```
24+
├─ my_project/
25+
│ ├─ index.html
26+
│ ├─ second.html
27+
```
28+
and the following command:
29+
```
30+
rsconnect deploy html -n local my_project
31+
```
32+
or this command:
33+
```
34+
rsconnect deploy html -n local my_project -e my_project/index.html
35+
```
36+
we will have a bundle which includes both `index.html` and `second.html`
37+
38+
- specifying a file in the path will result in that file* - not the entire directory - included in the deploy bundle
39+
40+
e.g.
41+
using the following directory,
42+
```
43+
├─ my_project/
44+
│ ├─ index.html
45+
│ ├─ second.html
46+
```
47+
and the following command:
48+
```
49+
rsconnect deploy html -n local my_project/second.html
50+
```
51+
we will have a bundle which includes `second.html`
52+
53+
- a note regarding entrypiont
54+
- providing an entrypoint is optional if there's an `index.html` inside the project directory, or if there's a *single* html file in the project directory.
55+
- if there are multiple html files in the project directory and it contains no `index.html`, we will get an exception when deploying that directory unless an entrypoint is specified.
56+
57+
- if we want to specify an entrypint, and we are executing the deploy command outside a project folder, we must specify the full path of the entrypoint:
58+
59+
```
60+
rsconnect deploy html -n local my_project -e my_project/second.html
61+
```
62+
63+
- if we want to specify an entrypint, and we are executing the deploy command inside the project folder, we can abbreviate the entrypoint, like so:
64+
```
65+
cd my_project
66+
rsconnect deploy html -n local ./ -e second.html
67+
```
68+
69+
70+
*Plus the manifest & other necessary files needed for the bundle to work on Connect.
71+
772
## [1.14.1] - 2023-02-09
873

974
### Fixed

rsconnect/bundle.py

+125-77
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(self, *args, **kwargs) -> None:
6868
quarto_inspection = kwargs.get("quarto_inspection")
6969
environment = kwargs.get("environment")
7070
image = kwargs.get("image")
71+
primary_html = kwargs.get("primary_html")
7172

7273
self.data["version"] = version if version else 1
7374
if environment:
@@ -82,6 +83,8 @@ def __init__(self, *args, **kwargs) -> None:
8283
"appmode": AppModes.UNKNOWN,
8384
}
8485
)
86+
if primary_html:
87+
self.data["metadata"]["primary_html"] = primary_html
8588

8689
if entrypoint:
8790
self.data["metadata"]["entrypoint"] = entrypoint
@@ -150,6 +153,18 @@ def entrypoint(self):
150153
def entrypoint(self, value):
151154
self.data["metadata"]["entrypoint"] = value
152155

156+
@property
157+
def primary_html(self):
158+
if "metadata" not in self.data:
159+
return None
160+
if "primary_html" in self.data["metadata"]:
161+
return self.data["metadata"]["primary_html"]
162+
return None
163+
164+
@primary_html.setter
165+
def primary_html(self, value):
166+
self.data["metadata"]["primary_html"] = value
167+
153168
def add_file(self, path):
154169
self.data["files"][path] = {"checksum": file_checksum(path)}
155170
return self
@@ -207,6 +222,12 @@ def flattened_entrypoint(self):
207222
raise RSConnectException("A valid entrypoint must be provided.")
208223
return relpath(self.entrypoint, dirname(self.entrypoint))
209224

225+
@property
226+
def flattened_primary_html(self):
227+
if self.primary_html is None:
228+
raise RSConnectException("A valid primary_html must be provided.")
229+
return relpath(self.primary_html, dirname(self.primary_html))
230+
210231
@property
211232
def flattened_copy(self):
212233
if self.entrypoint is None:
@@ -215,6 +236,8 @@ def flattened_copy(self):
215236
new_manifest.data["files"] = self.flattened_data
216237
new_manifest.buffer = self.flattened_buffer
217238
new_manifest.entrypoint = self.flattened_entrypoint
239+
if self.primary_html:
240+
new_manifest.primary_html = self.flattened_primary_html
218241
return new_manifest
219242

220243
def make_relative_to_deploy_dir(self):
@@ -817,61 +840,111 @@ def make_api_manifest(
817840
return manifest, relevant_files
818841

819842

820-
def make_html_bundle_content(
843+
def create_html_manifest(
821844
path: str,
822845
entrypoint: str,
823-
extra_files: typing.List[str],
824-
excludes: typing.List[str],
846+
extra_files: typing.List[str] = None,
847+
excludes: typing.List[str] = None,
825848
image: str = None,
826-
) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]:
827-
849+
**kwargs
850+
) -> Manifest:
828851
"""
829-
Makes a manifest for static html deployment.
852+
Creates and writes a manifest.json file for the given path.
830853
831854
:param path: the file, or the directory containing the files to deploy.
832855
:param entry_point: the main entry point for the API.
833-
:param extra_files: a sequence of any extra files to include in the bundle.
856+
:param environment: the Python environment to start with. This should be what's
857+
returned by the inspect_environment() function.
858+
:param app_mode: the application mode to assume. If this is None, the extension
859+
portion of the entry point file name will be used to derive one. Previous default = None.
860+
:param extra_files: any extra files that should be included in the manifest. Previous default = None.
834861
:param excludes: a sequence of glob patterns that will exclude matched files.
862+
:param force_generate: bool indicating whether to force generate manifest and related environment files.
835863
:param image: the optional docker image to be specified for off-host execution. Default = None.
836-
:return: the manifest and a list of the files involved.
864+
:return: the manifest data structure.
837865
"""
866+
if not path:
867+
raise RSConnectException("A valid path must be provided.")
838868
extra_files = list(extra_files) if extra_files else []
839-
entrypoint = entrypoint or infer_entrypoint(path=path, mimetype="text/html")
840-
if not entrypoint:
841-
raise RSConnectException("Unable to find a valid html entry point.")
869+
entrypoint_candidates = infer_entrypoint_candidates(path=abspath(path), mimetype="text/html")
842870

843-
if path.startswith(os.curdir):
844-
path = relpath(path)
845-
if entrypoint.startswith(os.curdir):
846-
entrypoint = relpath(entrypoint)
847-
extra_files = [relpath(f) if isfile(f) and f.startswith(os.curdir) else f for f in extra_files]
871+
deploy_dir = guess_deploy_dir(path, entrypoint)
872+
if len(entrypoint_candidates) <= 0:
873+
if entrypoint is None:
874+
raise RSConnectException("No valid entrypoint found.")
875+
entrypoint = abs_entrypoint(path, entrypoint)
876+
elif len(entrypoint_candidates) == 1:
877+
if entrypoint:
878+
entrypoint = abs_entrypoint(path, entrypoint)
879+
else:
880+
entrypoint = entrypoint_candidates[0]
881+
else: # len(entrypoint_candidates) > 1:
882+
if entrypoint is None:
883+
raise RSConnectException("No valid entrypoint found.")
884+
entrypoint = abs_entrypoint(path, entrypoint)
848885

849-
if is_environment_dir(path):
850-
excludes = list(excludes or []) + ["bin/", "lib/"]
886+
extra_files = validate_extra_files(deploy_dir, extra_files, use_abspath=True)
887+
excludes = list(excludes) if excludes else []
888+
excludes.extend(["manifest.json"])
889+
excludes.extend(list_environment_dirs(deploy_dir))
851890

852-
extra_files = extra_files or []
853-
skip = ["manifest.json"]
854-
extra_files = sorted(set(extra_files) - set(skip))
891+
manifest = Manifest(app_mode=AppModes.STATIC, entrypoint=entrypoint, primary_html=entrypoint, image=image)
892+
manifest.deploy_dir = deploy_dir
855893

856-
# Don't include these top-level files.
857-
excludes = list(excludes) if excludes else []
858-
excludes.append("manifest.json")
859-
if not isfile(path):
860-
excludes.extend(list_environment_dirs(path))
894+
file_list = create_file_list(path, extra_files, excludes, use_abspath=True)
895+
for abs_path in file_list:
896+
manifest.add_file(abs_path)
861897

862-
relevant_files = create_file_list(path, extra_files, excludes)
863-
manifest = make_html_manifest(entrypoint, image)
898+
return manifest
864899

865-
for rel_path in relevant_files:
866-
manifest_add_file(manifest, rel_path, path)
867900

868-
return manifest, relevant_files
901+
def make_html_bundle(
902+
path: str,
903+
entrypoint: str,
904+
extra_files: typing.List[str],
905+
excludes: typing.List[str],
906+
image: str = None,
907+
) -> typing.IO[bytes]:
908+
"""
909+
Create an html bundle, given a path and/or entrypoint.
910+
911+
The bundle contains a manifest.json file created for the given notebook entrypoint file.
912+
If the related environment file (requirements.txt) doesn't
913+
exist (or force_generate is set to True), the environment file will also be written.
914+
915+
:param path: the file, or the directory containing the files to deploy.
916+
:param entry_point: the main entry point.
917+
:param extra_files: a sequence of any extra files to include in the bundle.
918+
:param excludes: a sequence of glob patterns that will exclude matched files.
919+
:param force_generate: bool indicating whether to force generate manifest and related environment files.
920+
:param image: the optional docker image to be specified for off-host execution. Default = None.
921+
:return: a file-like object containing the bundle tarball.
922+
"""
923+
924+
manifest = create_html_manifest(**locals())
925+
if manifest.data.get("files") is None:
926+
raise RSConnectException("No valid files were found for the manifest.")
927+
928+
bundle = Bundle()
929+
for f in manifest.data["files"]:
930+
if f in manifest.buffer:
931+
continue
932+
bundle.add_file(f)
933+
for k, v in manifest.flattened_buffer.items():
934+
bundle.add_to_buffer(k, v)
935+
936+
manifest_flattened_copy_data = manifest.flattened_copy.data
937+
bundle.add_to_buffer("manifest.json", json.dumps(manifest_flattened_copy_data, indent=2))
938+
bundle.deploy_dir = manifest.deploy_dir
939+
940+
return bundle.to_file()
869941

870942

871943
def create_file_list(
872944
path: str,
873945
extra_files: typing.List[str] = None,
874946
excludes: typing.List[str] = None,
947+
use_abspath: bool = False,
875948
) -> typing.List[str]:
876949
"""
877950
Builds a full list of files under the given path that should be included
@@ -890,7 +963,8 @@ def create_file_list(
890963
file_set = set(extra_files) # type: typing.Set[str]
891964

892965
if isfile(path):
893-
file_set.add(Path(path).name)
966+
path_to_add = abspath(path) if use_abspath else path
967+
file_set.add(path_to_add)
894968
return sorted(file_set)
895969

896970
for cur_dir, sub_dirs, files in os.walk(path):
@@ -899,15 +973,16 @@ def create_file_list(
899973
if any(parent in exclude_paths for parent in Path(cur_dir).parents):
900974
continue
901975
for file in files:
902-
abs_path = os.path.join(cur_dir, file)
903-
rel_path = relpath(abs_path, path)
976+
cur_path = os.path.join(cur_dir, file)
977+
rel_path = relpath(cur_path, path)
904978

905-
if Path(abs_path) in exclude_paths:
979+
if Path(cur_path) in exclude_paths:
906980
continue
907981
if keep_manifest_specified_file(rel_path, exclude_paths | directories_to_ignore) and (
908-
rel_path in extra_files or not glob_set.matches(abs_path)
982+
rel_path in extra_files or not glob_set.matches(cur_path)
909983
):
910-
file_set.add(rel_path)
984+
path_to_add = abspath(cur_path) if use_abspath else rel_path
985+
file_set.add(path_to_add)
911986
return sorted(file_set)
912987

913988

@@ -930,48 +1005,20 @@ def infer_entrypoint_candidates(path, mimetype) -> List:
9301005
mimetype_filelist = defaultdict(list)
9311006

9321007
for file in os.listdir(path):
933-
rel_path = os.path.join(path, file)
934-
if not isfile(rel_path):
1008+
abs_path = os.path.join(path, file)
1009+
if not isfile(abs_path):
9351010
continue
936-
mimetype_filelist[guess_type(file)[0]].append(rel_path)
1011+
mimetype_filelist[guess_type(file)[0]].append(abs_path)
9371012
if file in default_mimetype_entrypoints[mimetype]:
938-
return file
1013+
return [abs_path]
9391014
return mimetype_filelist[mimetype] or []
9401015

9411016

942-
def make_html_bundle(
943-
path: str,
944-
entry_point: str,
945-
extra_files: typing.List[str],
946-
excludes: typing.List[str],
947-
image: str = None,
948-
) -> typing.IO[bytes]:
949-
"""
950-
Create an html bundle, given a path and a manifest.
951-
952-
:param path: the file, or the directory containing the files to deploy.
953-
:param entry_point: the main entry point for the API.
954-
:param extra_files: a sequence of any extra files to include in the bundle.
955-
:param excludes: a sequence of glob patterns that will exclude matched files.
956-
:param image: the optional docker image to be specified for off-host execution. Default = None.
957-
:return: a file-like object containing the bundle tarball.
958-
"""
959-
manifest, relevant_files = make_html_bundle_content(path, entry_point, extra_files, excludes, image)
960-
bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle")
961-
962-
with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle:
963-
bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest, indent=2))
964-
965-
for rel_path in relevant_files:
966-
bundle_add_file(bundle, rel_path, path)
967-
968-
# rewind file pointer
969-
bundle_file.seek(0)
970-
971-
return bundle_file
972-
973-
9741017
def guess_deploy_dir(path, entrypoint):
1018+
if path and not exists(path):
1019+
raise RSConnectException(f"Path {path} does not exist.")
1020+
if entrypoint and not exists(entrypoint):
1021+
raise RSConnectException(f"Entrypoint {entrypoint} does not exist.")
9751022
abs_path = abspath(path) if path else None
9761023
abs_entrypoint = abspath(entrypoint) if entrypoint else None
9771024
if not path and not entrypoint:
@@ -1228,7 +1275,7 @@ def validate_file_is_notebook(file_name):
12281275
raise RSConnectException("A Jupyter notebook (.ipynb) file is required here.")
12291276

12301277

1231-
def validate_extra_files(directory, extra_files):
1278+
def validate_extra_files(directory, extra_files, use_abspath=False):
12321279
"""
12331280
If the user specified a list of extra files, validate that they all exist and are
12341281
beneath the given directory and, if so, return a list of them made relative to that
@@ -1248,6 +1295,7 @@ def validate_extra_files(directory, extra_files):
12481295
raise RSConnectException("%s must be under %s." % (extra_file, directory))
12491296
if not exists(join(directory, extra_file)):
12501297
raise RSConnectException("Could not find file %s under %s" % (extra, directory))
1298+
extra_file = abspath(join(directory, extra_file)) if use_abspath else extra_file
12511299
result.append(extra_file)
12521300
return result
12531301

@@ -1646,9 +1694,9 @@ def create_voila_manifest(
16461694

16471695
manifest.add_to_buffer(join(deploy_dir, environment.filename), environment.contents)
16481696

1649-
file_list = create_file_list(path, extra_files, excludes)
1650-
for rel_path in file_list:
1651-
manifest.add_relative_path(rel_path)
1697+
file_list = create_file_list(path, extra_files, excludes, use_abspath=True)
1698+
for abs_path in file_list:
1699+
manifest.add_file(abs_path)
16521700
return manifest
16531701

16541702

0 commit comments

Comments
 (0)