rsconnect-python 1.30.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
BINARY_ENCODED_FILETYPES = [".cer", ".der"]
|
|
6
|
+
TEXT_ENCODED_FILETYPES = [".ca-bundle", ".crt", ".key", ".pem"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_certificate_file(location: str) -> str | bytes:
|
|
10
|
+
"""Reads a certificate file from disk.
|
|
11
|
+
|
|
12
|
+
The file type (suffix) is used to determine the file encoding.
|
|
13
|
+
Assumption are made based on standard SSL practices.
|
|
14
|
+
|
|
15
|
+
Files ending in '.cer' and '.der' are assumed DER (Distinguished
|
|
16
|
+
Encoding Rules) files encoded in binary format.
|
|
17
|
+
|
|
18
|
+
Files ending in '.ca-bundle', '.crt', '.key', and '.pem' are PEM
|
|
19
|
+
(Privacy Enhanced Mail) files encoded in plain-text format.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
path = Path(location)
|
|
23
|
+
suffix = path.suffix
|
|
24
|
+
|
|
25
|
+
if suffix in BINARY_ENCODED_FILETYPES:
|
|
26
|
+
with open(path, "rb") as bFile:
|
|
27
|
+
return bFile.read()
|
|
28
|
+
|
|
29
|
+
if suffix in TEXT_ENCODED_FILETYPES:
|
|
30
|
+
with open(path, "r") as tFile:
|
|
31
|
+
return tFile.read()
|
|
32
|
+
|
|
33
|
+
types = BINARY_ENCODED_FILETYPES + TEXT_ENCODED_FILETYPES
|
|
34
|
+
types = sorted(types)
|
|
35
|
+
types = [f"'{_}'" for _ in types]
|
|
36
|
+
human_readable_string = ", ".join(types[:-1]) + ", or " + types[-1]
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
f"The certificate file type is not recognized. Expected {human_readable_string}. Found '{suffix}'."
|
|
39
|
+
)
|
rsconnect/environment.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Detects the configuration of a Python environment.
|
|
2
|
+
|
|
3
|
+
Given a directory and a Python executable, this module inspects the environment
|
|
4
|
+
and returns information about the Python version and the environment itself.
|
|
5
|
+
|
|
6
|
+
To inspect the environment it relies on a subprocess that runs the `rsconnect.subprocesses.inspect_environment`
|
|
7
|
+
module. This module is responsible for gathering the environment information and returning it in a JSON format.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import typing
|
|
11
|
+
import sys
|
|
12
|
+
import dataclasses
|
|
13
|
+
import pprint
|
|
14
|
+
import subprocess
|
|
15
|
+
import json
|
|
16
|
+
import pathlib
|
|
17
|
+
import os.path
|
|
18
|
+
import enum
|
|
19
|
+
|
|
20
|
+
from . import pyproject
|
|
21
|
+
from .log import logger
|
|
22
|
+
from .exception import RSConnectException
|
|
23
|
+
from .subprocesses.inspect_environment import EnvironmentData, MakeEnvironmentData as _MakeEnvironmentData
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
except ImportError: # Python <3.11
|
|
30
|
+
|
|
31
|
+
class StrEnum(str, enum.Enum):
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return str(self.value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PackageInstaller(StrEnum):
|
|
37
|
+
PIP = "pip"
|
|
38
|
+
UV = "uv"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Environment:
|
|
42
|
+
"""A Python project environment,
|
|
43
|
+
|
|
44
|
+
The data is loaded from a rsconnect.utils.environment json response,
|
|
45
|
+
the environment contains all the information provided by :class:`EnvironmentData` plus
|
|
46
|
+
the environment python interpreter and the python interpreter version requirement.
|
|
47
|
+
|
|
48
|
+
The goal is to capture all the information needed to replicate such environment.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
DATA_FIELDS = {f.name for f in dataclasses.fields(EnvironmentData)}
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
data: EnvironmentData,
|
|
56
|
+
python_interpreter: typing.Optional[str] = None,
|
|
57
|
+
python_version_requirement: typing.Optional[str] = None,
|
|
58
|
+
):
|
|
59
|
+
self._data = data
|
|
60
|
+
|
|
61
|
+
# Fields that are not loaded from the environment subprocess
|
|
62
|
+
self.python_version_requirement = python_version_requirement
|
|
63
|
+
self.python_interpreter = python_interpreter
|
|
64
|
+
# Optional override of server install behavior. If None, server-driven
|
|
65
|
+
# default is used.
|
|
66
|
+
self.package_manager_allow_uv: typing.Optional[bool] = None
|
|
67
|
+
|
|
68
|
+
def __getattr__(self, name: str) -> typing.Any:
|
|
69
|
+
# We directly proxy the attributes of the EnvironmentData object
|
|
70
|
+
# so that schema changes can be handled in EnvironmentData exclusively.
|
|
71
|
+
return getattr(self._data, name)
|
|
72
|
+
|
|
73
|
+
def __setattr__(self, name: str, value: typing.Any) -> None:
|
|
74
|
+
if name in self.DATA_FIELDS:
|
|
75
|
+
# proxy the attribute to the underlying EnvironmentData object
|
|
76
|
+
self._data = self._data._replace(**{name: value})
|
|
77
|
+
else:
|
|
78
|
+
super().__setattr__(name, value)
|
|
79
|
+
|
|
80
|
+
def __eq__(self, other: typing.Any) -> bool:
|
|
81
|
+
if not isinstance(other, Environment):
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
self._data == other._data
|
|
86
|
+
and self.python_interpreter == other.python_interpreter
|
|
87
|
+
and self.python_version_requirement == other.python_version_requirement
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
data = self._data._asdict()
|
|
92
|
+
data.pop("contents", None) # Remove contents as it's too long to display
|
|
93
|
+
return (
|
|
94
|
+
f"Environment({data}, "
|
|
95
|
+
f"python_interpreter={self.python_interpreter}, "
|
|
96
|
+
f"python_version_requirement={self.python_version_requirement})"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(
|
|
101
|
+
cls,
|
|
102
|
+
data: typing.Dict[str, typing.Any],
|
|
103
|
+
python_interpreter: typing.Optional[str] = None,
|
|
104
|
+
python_version_requirement: typing.Optional[str] = None,
|
|
105
|
+
) -> "Environment":
|
|
106
|
+
"""Create an Environment instance from the dictionary representation of EnvironmentData."""
|
|
107
|
+
return cls(
|
|
108
|
+
_MakeEnvironmentData(**data),
|
|
109
|
+
python_interpreter=python_interpreter,
|
|
110
|
+
python_version_requirement=python_version_requirement,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def create_python_environment(
|
|
115
|
+
cls,
|
|
116
|
+
directory: str,
|
|
117
|
+
requirements_file: typing.Optional[str] = "requirements.txt",
|
|
118
|
+
python: typing.Optional[str] = None,
|
|
119
|
+
override_python_version: typing.Optional[str] = None,
|
|
120
|
+
app_file: typing.Optional[str] = None,
|
|
121
|
+
package_manager: typing.Optional[PackageInstaller] = None,
|
|
122
|
+
) -> "Environment":
|
|
123
|
+
"""Given a project directory and a Python executable, return Environment information.
|
|
124
|
+
|
|
125
|
+
If no Python executable is provided, the current system Python executable is used.
|
|
126
|
+
|
|
127
|
+
:param directory: the project directory to inspect.
|
|
128
|
+
:param requirements_file: requirements file name relative to the project directory. If None,
|
|
129
|
+
capture the environment via pip freeze.
|
|
130
|
+
:param python: the Python executable of the environment to use for inspection.
|
|
131
|
+
:param override_python_version: the Python version required by the project.
|
|
132
|
+
:param app_file: the main application file to use for inspection.
|
|
133
|
+
|
|
134
|
+
:return: a tuple containing the Python executable of the environment and the Environment object.
|
|
135
|
+
"""
|
|
136
|
+
if app_file is None:
|
|
137
|
+
module_file = fake_module_file_from_directory(directory)
|
|
138
|
+
else:
|
|
139
|
+
module_file = app_file
|
|
140
|
+
|
|
141
|
+
_warn_on_ignored_manifest(directory)
|
|
142
|
+
_warn_if_environment_directory(directory)
|
|
143
|
+
|
|
144
|
+
python_version_requirement = pyproject.detect_python_version_requirement(directory)
|
|
145
|
+
_warn_on_missing_python_version(python_version_requirement)
|
|
146
|
+
|
|
147
|
+
_check_requirements_file(directory, requirements_file)
|
|
148
|
+
|
|
149
|
+
if python is not None:
|
|
150
|
+
# TODO: Remove the option in a future release
|
|
151
|
+
logger.warning(
|
|
152
|
+
"On modern Posit Connect versions, the --python option won't influence "
|
|
153
|
+
"the Python version used to deploy the application anymore. "
|
|
154
|
+
"Please use a .python-version file to force a specific interpreter version."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if override_python_version:
|
|
158
|
+
# TODO: Remove the option in a future release
|
|
159
|
+
logger.warning(
|
|
160
|
+
"The --override-python-version option is deprecated, "
|
|
161
|
+
"please use a .python-version file to force a specific interpreter version."
|
|
162
|
+
)
|
|
163
|
+
python_version_requirement = f"=={override_python_version}"
|
|
164
|
+
|
|
165
|
+
# with cli_feedback("Inspecting Python environment"):
|
|
166
|
+
environment = cls._get_python_env_info(module_file, python, requirements_file=requirements_file)
|
|
167
|
+
environment.python_version_requirement = python_version_requirement
|
|
168
|
+
|
|
169
|
+
if override_python_version:
|
|
170
|
+
# Retaing backward compatibility with old Connect versions
|
|
171
|
+
# that didn't support environment.python.requires
|
|
172
|
+
environment.python = override_python_version
|
|
173
|
+
|
|
174
|
+
if package_manager is not None:
|
|
175
|
+
try:
|
|
176
|
+
selected_package_manager = PackageInstaller(package_manager)
|
|
177
|
+
except ValueError:
|
|
178
|
+
raise RSConnectException("Unsupported package manager: %s" % package_manager) from None
|
|
179
|
+
# Override the package manager name recorded by inspector
|
|
180
|
+
environment.package_manager = selected_package_manager # type: ignore[attr-defined]
|
|
181
|
+
# Derive allow_uv from selection
|
|
182
|
+
environment.package_manager_allow_uv = selected_package_manager is PackageInstaller.UV
|
|
183
|
+
|
|
184
|
+
if requirements_file is None:
|
|
185
|
+
_warn_on_ignored_requirements(directory, environment.filename)
|
|
186
|
+
|
|
187
|
+
return environment
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def _get_python_env_info(
|
|
191
|
+
cls,
|
|
192
|
+
file_name: str,
|
|
193
|
+
python: typing.Optional[str],
|
|
194
|
+
requirements_file: typing.Optional[str] = "requirements.txt",
|
|
195
|
+
) -> "Environment":
|
|
196
|
+
"""
|
|
197
|
+
Gathers the python and environment information relating to the specified file
|
|
198
|
+
with an eye to deploy it.
|
|
199
|
+
|
|
200
|
+
:param file_name: the primary file being deployed.
|
|
201
|
+
:param python: the optional name of a Python executable.
|
|
202
|
+
:param requirements_file: which requirements file to read. If None, generate via pip freeze.
|
|
203
|
+
:return: information about the version of Python in use plus some environmental
|
|
204
|
+
stuff.
|
|
205
|
+
"""
|
|
206
|
+
python = which_python(python)
|
|
207
|
+
logger.debug("Python: %s" % python)
|
|
208
|
+
environment = cls._inspect_environment(python, os.path.dirname(file_name), requirements_file=requirements_file)
|
|
209
|
+
if environment.error:
|
|
210
|
+
raise RSConnectException(environment.error)
|
|
211
|
+
logger.debug("Python: %s" % python)
|
|
212
|
+
logger.debug("Environment: %s" % pprint.pformat(environment._asdict()))
|
|
213
|
+
return environment
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def _inspect_environment(
|
|
217
|
+
cls,
|
|
218
|
+
python: str,
|
|
219
|
+
directory: str,
|
|
220
|
+
requirements_file: typing.Optional[str] = "requirements.txt",
|
|
221
|
+
check_output: typing.Callable[..., bytes] = subprocess.check_output,
|
|
222
|
+
) -> "Environment":
|
|
223
|
+
"""Run the environment inspector using the specified python binary.
|
|
224
|
+
|
|
225
|
+
Returns a dictionary of information about the environment,
|
|
226
|
+
or containing an "error" field if an error occurred.
|
|
227
|
+
"""
|
|
228
|
+
args = [python, "-m", "rsconnect.subprocesses.inspect_environment"]
|
|
229
|
+
args.extend(["--requirements-file", requirements_file or "none"])
|
|
230
|
+
args.append(directory)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
environment_json = check_output(args, text=True)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise RSConnectException("Error inspecting environment (subprocess failed)") from e
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
environment_data = json.loads(environment_json)
|
|
239
|
+
except json.JSONDecodeError as e:
|
|
240
|
+
raise RSConnectException("Error parsing environment JSON") from e
|
|
241
|
+
|
|
242
|
+
if "error" in environment_data:
|
|
243
|
+
system_error_message = environment_data.get("error")
|
|
244
|
+
if system_error_message:
|
|
245
|
+
raise RSConnectException(f"Error creating environment: {system_error_message}")
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
return cls.from_dict(environment_data, python_interpreter=python)
|
|
249
|
+
except TypeError as e:
|
|
250
|
+
raise RSConnectException("Error constructing environment object") from e
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def which_python(python: typing.Optional[str] = None) -> str:
|
|
254
|
+
"""Determines which Python executable to use.
|
|
255
|
+
|
|
256
|
+
If the :param python: is provided, then validation is performed to check if the path is an executable file. If
|
|
257
|
+
None, the invoking system Python executable location is returned.
|
|
258
|
+
|
|
259
|
+
:param python: (Optional) path to a python executable.
|
|
260
|
+
:return: :param python: or `sys.executable`.
|
|
261
|
+
"""
|
|
262
|
+
if python is None:
|
|
263
|
+
return sys.executable
|
|
264
|
+
if not os.path.exists(python):
|
|
265
|
+
raise RSConnectException(f"The path '{python}' does not exist. Expected a Python executable.")
|
|
266
|
+
if os.path.isdir(python):
|
|
267
|
+
raise RSConnectException(f"The path '{python}' is a directory. Expected a Python executable.")
|
|
268
|
+
if not os.access(python, os.X_OK):
|
|
269
|
+
raise RSConnectException(f"The path '{python}' is not executable. Expected a Python executable")
|
|
270
|
+
return python
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def fake_module_file_from_directory(directory: str) -> str:
|
|
274
|
+
"""
|
|
275
|
+
Takes a directory and invents a properly named file that though possibly fake,
|
|
276
|
+
can be used for other name/title derivation.
|
|
277
|
+
|
|
278
|
+
:param directory: the directory to start with.
|
|
279
|
+
:return: the directory plus the (potentially) fake module file.
|
|
280
|
+
"""
|
|
281
|
+
app_name = os.path.abspath(directory)
|
|
282
|
+
app_name = os.path.dirname(app_name) if app_name.endswith(os.path.sep) else os.path.basename(app_name)
|
|
283
|
+
return os.path.join(directory, app_name + ".py")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def is_environment_dir(directory: typing.Union[str, pathlib.Path]) -> bool:
|
|
287
|
+
"""Detect whether `directory` is a virtualenv"""
|
|
288
|
+
|
|
289
|
+
# A virtualenv will have Python at ./bin/python
|
|
290
|
+
python_path = os.path.join(directory, "bin", "python")
|
|
291
|
+
# But on Windows, it's at Scripts\Python.exe
|
|
292
|
+
win_path = os.path.join(directory, "Scripts", "Python.exe")
|
|
293
|
+
return os.path.exists(python_path) or os.path.exists(win_path)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def list_environment_dirs(directory: typing.Union[str, pathlib.Path]) -> typing.List[str]:
|
|
297
|
+
"""Returns a list of subdirectories in `directory` that appear to contain virtual environments."""
|
|
298
|
+
envs: typing.List[str] = []
|
|
299
|
+
|
|
300
|
+
for name in os.listdir(directory):
|
|
301
|
+
path = os.path.join(directory, name)
|
|
302
|
+
if is_environment_dir(path):
|
|
303
|
+
envs.append(name)
|
|
304
|
+
return envs
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _warn_on_ignored_manifest(directory: str) -> None:
|
|
308
|
+
"""
|
|
309
|
+
Checks for the existence of a file called manifest.json in the given directory.
|
|
310
|
+
If it's there, a warning noting that it will be ignored will be printed.
|
|
311
|
+
|
|
312
|
+
:param directory: the directory to check in.
|
|
313
|
+
"""
|
|
314
|
+
if os.path.exists(os.path.join(directory, "manifest.json")):
|
|
315
|
+
click.secho(
|
|
316
|
+
" Warning: the existing manifest.json file will not be used or considered.",
|
|
317
|
+
fg="yellow",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _check_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None:
|
|
322
|
+
"""
|
|
323
|
+
Verify that a requirements file exists inside the deployment directory.
|
|
324
|
+
|
|
325
|
+
:param directory: the directory to check in.
|
|
326
|
+
:param requirements_file: the name of the requirements file, or None to skip the check.
|
|
327
|
+
"""
|
|
328
|
+
if requirements_file is None:
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
directory_path = pathlib.Path(directory)
|
|
332
|
+
requirements_file_path = directory_path / pathlib.Path(requirements_file)
|
|
333
|
+
if directory_path not in requirements_file_path.parents:
|
|
334
|
+
click.secho(
|
|
335
|
+
" Warning: The requirements file '%s' is outside of the deployment directory.\n" % requirements_file,
|
|
336
|
+
fg="red",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if not requirements_file_path.exists():
|
|
340
|
+
raise RSConnectException(
|
|
341
|
+
"The requirements file '%s' does not exist in '%s'.\n"
|
|
342
|
+
"Please create the file or specify a different file with --requirements-file.\n"
|
|
343
|
+
"To have the requirements file generated using pip freeze, pass --force-generate."
|
|
344
|
+
% (requirements_file, directory)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _warn_if_environment_directory(directory: typing.Union[str, pathlib.Path]) -> None:
|
|
349
|
+
"""
|
|
350
|
+
Issue a warning if the deployment directory is itself a virtualenv (yikes!).
|
|
351
|
+
|
|
352
|
+
:param directory: the directory to check in.
|
|
353
|
+
"""
|
|
354
|
+
if is_environment_dir(directory):
|
|
355
|
+
click.secho(
|
|
356
|
+
" Warning: The deployment directory appears to be a python virtual environment.\n"
|
|
357
|
+
" Python libraries and binaries will be excluded from the deployment.",
|
|
358
|
+
fg="yellow",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _warn_on_ignored_requirements(directory: str, requirements_file_name: str) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Checks for the existence of a file called manifest.json in the given directory.
|
|
365
|
+
If it's there, a warning noting that it will be ignored will be printed.
|
|
366
|
+
|
|
367
|
+
:param directory: the directory to check in.
|
|
368
|
+
:param requirements_file_name: the name of the requirements file.
|
|
369
|
+
"""
|
|
370
|
+
if os.path.exists(os.path.join(directory, requirements_file_name)):
|
|
371
|
+
click.secho(
|
|
372
|
+
" Warning: the existing %s file will not be used or considered." % requirements_file_name,
|
|
373
|
+
fg="yellow",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _warn_on_missing_python_version(version_constraint: typing.Optional[str]) -> None:
|
|
378
|
+
"""
|
|
379
|
+
Check that the project has a Python version constraint requested.
|
|
380
|
+
If it doesn't warn the user that it should be specified.
|
|
381
|
+
|
|
382
|
+
:param version_constraint: the version constraint in the project.
|
|
383
|
+
"""
|
|
384
|
+
if version_constraint is None:
|
|
385
|
+
click.secho(
|
|
386
|
+
" Warning: Python version constraint missing from pyproject.toml, setup.cfg or .python-version\n"
|
|
387
|
+
" Connect will guess the version to use based on local environment.\n"
|
|
388
|
+
" Consider specifying a Python version constraint.",
|
|
389
|
+
fg="yellow",
|
|
390
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Detects the configuration of a Node.js environment.
|
|
2
|
+
|
|
3
|
+
Given a directory containing a package.json file, this module inspects
|
|
4
|
+
the local Node.js/npm installation and returns information needed to
|
|
5
|
+
build the deployment manifest.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import locale
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .exception import RSConnectException
|
|
17
|
+
from .log import logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NodeEnvironment:
|
|
21
|
+
"""A Node.js project environment for deployment.
|
|
22
|
+
|
|
23
|
+
Captures Node.js version, npm version, and package.json contents
|
|
24
|
+
needed for the manifest.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
node_version: str,
|
|
30
|
+
npm_version: str,
|
|
31
|
+
package_file: str,
|
|
32
|
+
package_contents: str,
|
|
33
|
+
has_lock_file: bool,
|
|
34
|
+
locale: str,
|
|
35
|
+
):
|
|
36
|
+
self.node_version = node_version
|
|
37
|
+
self.npm_version = npm_version
|
|
38
|
+
self.package_file = package_file
|
|
39
|
+
self.package_contents = package_contents
|
|
40
|
+
self.has_lock_file = has_lock_file
|
|
41
|
+
self.locale = locale
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def create(
|
|
45
|
+
cls,
|
|
46
|
+
directory: str,
|
|
47
|
+
node_executable: Optional[str] = None,
|
|
48
|
+
) -> NodeEnvironment:
|
|
49
|
+
"""Detect Node.js environment from a project directory.
|
|
50
|
+
|
|
51
|
+
:param directory: path to the project directory containing package.json.
|
|
52
|
+
:param node_executable: optional path to the node binary. Defaults to "node" on PATH.
|
|
53
|
+
:return: a NodeEnvironment instance.
|
|
54
|
+
"""
|
|
55
|
+
node_executable = node_executable or "node"
|
|
56
|
+
|
|
57
|
+
package_json_path = os.path.join(directory, "package.json")
|
|
58
|
+
if not os.path.exists(package_json_path):
|
|
59
|
+
raise RSConnectException(
|
|
60
|
+
f"No package.json found in '{directory}'. A package.json file is required to deploy Node.js content."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with open(package_json_path, encoding="utf-8") as f:
|
|
64
|
+
package_contents = f.read()
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
json.loads(package_contents)
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
raise RSConnectException(f"Failed to parse package.json: {e}")
|
|
70
|
+
|
|
71
|
+
node_version = _detect_version(node_executable, "--version", "Node.js")
|
|
72
|
+
npm_version = _detect_version("npm", "--version", "npm")
|
|
73
|
+
|
|
74
|
+
has_lock_file = os.path.exists(os.path.join(directory, "package-lock.json"))
|
|
75
|
+
if not has_lock_file:
|
|
76
|
+
raise RSConnectException(
|
|
77
|
+
f"No package-lock.json found in '{directory}'. "
|
|
78
|
+
"Connect installs Node.js dependencies with npm. "
|
|
79
|
+
"Both package.json and package-lock.json are required to deploy Node.js content."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
env_locale = locale.getlocale()[0] or "en_US"
|
|
83
|
+
|
|
84
|
+
return cls(
|
|
85
|
+
node_version=node_version,
|
|
86
|
+
npm_version=npm_version,
|
|
87
|
+
package_file="package.json",
|
|
88
|
+
package_contents=package_contents,
|
|
89
|
+
has_lock_file=has_lock_file,
|
|
90
|
+
locale=env_locale,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _detect_version(executable: str, flag: str, label: str) -> str:
|
|
95
|
+
"""Run an executable with a version flag and return the version string."""
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
[executable, flag],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
timeout=10,
|
|
102
|
+
)
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
raise RSConnectException(f"{label} returned exit code {result.returncode}: {result.stderr.strip()}")
|
|
105
|
+
version = result.stdout.strip().lstrip("v")
|
|
106
|
+
if not version:
|
|
107
|
+
raise RSConnectException(f"{label} returned empty version string.")
|
|
108
|
+
logger.debug(f"Detected {label} version: {version}")
|
|
109
|
+
return version
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
raise RSConnectException(
|
|
112
|
+
f"Could not find '{executable}' on PATH. Please install {label} or specify the path with --node."
|
|
113
|
+
)
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
raise RSConnectException(f"Timed out detecting {label} version.")
|