Skip to content

Commit d97d49e

Browse files
committed
work in progress
1 parent 9f7c114 commit d97d49e

File tree

7 files changed

+333
-256
lines changed

7 files changed

+333
-256
lines changed

rsconnect/actions.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
make_quarto_source_bundle,
3838
read_manifest_file,
3939
)
40-
from .environment import Environment, EnvironmentException
40+
from .environment import Environment
4141
from .exception import RSConnectException
4242
from .log import VERBOSE, logger
4343
from .models import AppMode, AppModes
@@ -78,8 +78,6 @@ def failed(err: str):
7878
passed()
7979
except RSConnectException as exc:
8080
failed("Error: " + exc.message)
81-
except EnvironmentException as exc:
82-
failed("Error: " + str(exc))
8381
except Exception as exc:
8482
traceback.print_exc()
8583
failed("Internal error: " + str(exc))

rsconnect/bundle.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353

5454
import click
5555

56-
from .environment import Environment, MakeEnvironment
56+
from . import pyproject
57+
from .environment import Environment
5758
from .exception import RSConnectException
5859
from .log import VERBOSE, logger
5960
from .models import AppMode, AppModes, GlobSet
@@ -1669,6 +1670,22 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str) -
16691670
)
16701671

16711672

1673+
def _warn_on_missing_python_version(version_constraint: Optional[str]) -> None:
1674+
"""
1675+
Check that the project has a Python version constraint requested.
1676+
If it doesn't warn the user that it should be specified.
1677+
1678+
:param version_constraint: the version constraint in the project.
1679+
"""
1680+
if version_constraint is None:
1681+
click.secho(
1682+
" Warning: Python version constraint missing from pyproject.toml or .python-version\n"
1683+
" Connect will guess the version to use based on local environment.\n"
1684+
" Consider specifying a Python version constraint.",
1685+
fg="yellow",
1686+
)
1687+
1688+
16721689
def fake_module_file_from_directory(directory: str) -> str:
16731690
"""
16741691
Takes a directory and invents a properly named file that though possibly fake,
@@ -1717,7 +1734,7 @@ def inspect_environment(
17171734
if force_generate:
17181735
flags.append("f")
17191736

1720-
args = [python, "-m", "rsconnect.environment"]
1737+
args = [python, "-m", "rsconnect.subprocesses.environment"]
17211738
if flags:
17221739
args.append("-" + "".join(flags))
17231740
args.append(directory)
@@ -1732,20 +1749,23 @@ def inspect_environment(
17321749
except json.JSONDecodeError as e:
17331750
raise RSConnectException("Error parsing environment JSON") from e
17341751

1735-
try:
1736-
return MakeEnvironment(**environment_data)
1737-
except TypeError as e:
1752+
if "error" in environment_data:
17381753
system_error_message = environment_data.get("error")
17391754
if system_error_message:
17401755
raise RSConnectException(f"Error creating environment: {system_error_message}") from e
1756+
1757+
try:
1758+
return Environment.from_json(environment_data)
1759+
except TypeError as e:
17411760
raise RSConnectException("Error constructing environment object") from e
17421761

17431762

1744-
def get_python_env_info(
1763+
def _get_python_env_info(
17451764
file_name: str,
17461765
python: str | None,
17471766
force_generate: bool = False,
17481767
override_python_version: str | None = None,
1768+
python_version_requirement: str | None = None,
17491769
) -> tuple[str, Environment]:
17501770
"""
17511771
Gathers the python and environment information relating to the specified file
@@ -1766,8 +1786,11 @@ def get_python_env_info(
17661786
logger.debug("Python: %s" % python)
17671787
logger.debug("Environment: %s" % pformat(environment._asdict()))
17681788

1789+
if python_version_requirement:
1790+
environment.python_version_requirement = python_version_requirement
1791+
17691792
if override_python_version:
1770-
environment = environment._replace(python=override_python_version)
1793+
environment = environment.python = override_python_version
17711794

17721795
return python, environment
17731796

@@ -2261,17 +2284,29 @@ def create_python_environment(
22612284
force_generate: bool = False,
22622285
python: Optional[str] = None,
22632286
override_python_version: Optional[str] = None,
2287+
app_file: Optional[str] = None,
22642288
) -> Environment:
2265-
module_file = fake_module_file_from_directory(directory)
2289+
if app_file is None:
2290+
module_file = fake_module_file_from_directory(directory)
2291+
else:
2292+
module_file = app_file
22662293

22672294
# click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url))
2268-
22692295
_warn_on_ignored_manifest(directory)
22702296
_warn_if_no_requirements_file(directory)
22712297
_warn_if_environment_directory(directory)
22722298

2299+
python_version_requirement = pyproject.detect_python_version_requirement(directory)
2300+
_warn_on_missing_python_version(python_version_requirement)
2301+
2302+
if override_python_version:
2303+
# TODO: --override-python-version should be deprecated in the future
2304+
# and instead we should suggest the user sets it in .python-version
2305+
# or pyproject.toml
2306+
python_version_requirement = override_python_version
2307+
22732308
# with cli_feedback("Inspecting Python environment"):
2274-
_, environment = get_python_env_info(module_file, python, force_generate, override_python_version)
2309+
_, environment = _get_python_env_info(module_file, python, force_generate, override_python_version)
22752310

22762311
if force_generate:
22772312
_warn_on_ignored_requirements(directory, environment.filename)

rsconnect/environment.py

Lines changed: 28 additions & 226 deletions
Original file line numberDiff line numberDiff line change
@@ -1,233 +1,35 @@
1-
#!/usr/bin/env python
2-
"""
3-
Environment data class abstraction that is usable as an executable module
1+
import typing
2+
import dataclasses
43

5-
```bash
6-
python -m rsconnect.environment
7-
```
8-
"""
9-
from __future__ import annotations
4+
from .subprocesses.environment import EnvironmentData, MakeEnvironmentData as _MakeEnvironmentData
105

11-
import datetime
12-
import json
13-
import locale
14-
import os
15-
import re
16-
import subprocess
17-
import sys
18-
from dataclasses import asdict, dataclass, replace
19-
from typing import Callable, Optional
206

21-
version_re = re.compile(r"\d+\.\d+(\.\d+)?")
22-
exec_dir = os.path.dirname(sys.executable)
23-
24-
25-
@dataclass(frozen=True)
267
class Environment:
27-
contents: str
28-
filename: str
29-
locale: str
30-
package_manager: str
31-
pip: str
32-
python: str
33-
source: str
34-
error: str | None
35-
36-
def _asdict(self):
37-
return asdict(self)
38-
39-
def _replace(self, **kwargs: object):
40-
return replace(self, **kwargs)
41-
42-
43-
def MakeEnvironment(
44-
contents: str,
45-
filename: str,
46-
locale: str,
47-
package_manager: str,
48-
pip: str,
49-
python: str,
50-
source: str,
51-
error: Optional[str] = None,
52-
**kwargs: object, # provides compatibility where we no longer support some older properties
53-
) -> Environment:
54-
return Environment(contents, filename, locale, package_manager, pip, python, source, error)
55-
56-
57-
class EnvironmentException(Exception):
58-
pass
59-
60-
61-
def detect_environment(dirname: str, force_generate: bool = False) -> Environment:
62-
"""Determine the python dependencies in the environment.
63-
64-
`pip freeze` will be used to introspect the environment.
65-
66-
:param: dirname Directory name
67-
:param: force_generate Force the generation of an environment
68-
:return: a dictionary containing the package spec filename and contents if successful,
69-
or a dictionary containing `error` on failure.
70-
"""
71-
72-
if force_generate:
73-
result = pip_freeze()
74-
else:
75-
result = output_file(dirname, "requirements.txt", "pip") or pip_freeze()
76-
77-
if result is not None:
78-
result["python"] = get_python_version()
79-
result["pip"] = get_version("pip")
80-
result["locale"] = get_default_locale()
81-
82-
return MakeEnvironment(**result)
83-
84-
85-
def get_python_version() -> str:
86-
v = sys.version_info
87-
return "%d.%d.%d" % (v[0], v[1], v[2])
88-
89-
90-
def get_default_locale(locale_source: Callable[..., tuple[str | None, str | None]] = locale.getlocale):
91-
result = ".".join([item or "" for item in locale_source()])
92-
return "" if result == "." else result
93-
94-
95-
def get_version(module: str):
96-
try:
97-
args = [sys.executable, "-m", module, "--version"]
98-
proc = subprocess.Popen(
99-
args,
100-
stdout=subprocess.PIPE,
101-
stderr=subprocess.PIPE,
102-
universal_newlines=True,
103-
)
104-
stdout, _stderr = proc.communicate()
105-
match = version_re.search(stdout)
106-
if match:
107-
return match.group()
108-
109-
msg = "Failed to get version of '%s' from the output of: %s" % (
110-
module,
111-
" ".join(args),
112-
)
113-
raise EnvironmentException(msg)
114-
except Exception as exception:
115-
raise EnvironmentException("Error getting '%s' version: %s" % (module, str(exception)))
116-
117-
118-
def output_file(dirname: str, filename: str, package_manager: str):
119-
"""Read an existing package spec file.
8+
"""A project environment,
1209
121-
Returns a dictionary containing the filename and contents
122-
if successful, None if the file does not exist,
123-
or a dictionary containing 'error' on failure.
10+
The data is loaded from a rsconnect.utils.environment json response
12411
"""
125-
try:
126-
path = os.path.join(dirname, filename)
127-
if not os.path.exists(path):
128-
return None
129-
130-
with open(path, "r") as f:
131-
data = f.read()
132-
133-
data = "\n".join([line for line in data.split("\n") if "rsconnect" not in line])
134-
135-
return {
136-
"filename": filename,
137-
"contents": data,
138-
"source": "file",
139-
"package_manager": package_manager,
140-
}
141-
except Exception as exception:
142-
raise EnvironmentException("Error reading %s: %s" % (filename, str(exception)))
143-
144-
145-
def pip_freeze():
146-
"""Inspect the environment using `pip freeze --disable-pip-version-check version`.
147-
148-
Returns a dictionary containing the filename
149-
(always 'requirements.txt') and contents if successful,
150-
or a dictionary containing 'error' on failure.
151-
"""
152-
try:
153-
proc = subprocess.Popen(
154-
[sys.executable, "-m", "pip", "freeze", "--disable-pip-version-check"],
155-
stdout=subprocess.PIPE,
156-
stderr=subprocess.PIPE,
157-
universal_newlines=True,
158-
)
159-
160-
pip_stdout, pip_stderr = proc.communicate()
161-
pip_status = proc.returncode
162-
except Exception as exception:
163-
raise EnvironmentException("Error during pip freeze: %s" % str(exception))
164-
165-
if pip_status != 0:
166-
msg = pip_stderr or ("exited with code %d" % pip_status)
167-
raise EnvironmentException("Error during pip freeze: %s" % msg)
168-
169-
pip_stdout = filter_pip_freeze_output(pip_stdout)
170-
171-
pip_stdout = (
172-
"# requirements.txt generated by rsconnect-python on "
173-
+ str(datetime.datetime.now(datetime.timezone.utc))
174-
+ "\n"
175-
+ pip_stdout
176-
)
177-
178-
return {
179-
"filename": "requirements.txt",
180-
"contents": pip_stdout,
181-
"source": "pip_freeze",
182-
"package_manager": "pip",
183-
}
184-
185-
186-
def filter_pip_freeze_output(pip_stdout: str):
187-
# Filter out dependency on `rsconnect` and ignore output lines from pip which start with `[notice]`
188-
return "\n".join(
189-
[line for line in pip_stdout.split("\n") if (("rsconnect" not in line) and (line.find("[notice]") != 0))]
190-
)
191-
192-
193-
def strip_ref(line: str):
194-
# remove erroneous conda build paths that will break pip install
195-
return line.split(" @ file:", 1)[0].strip()
196-
197-
198-
def exclude(line: str):
199-
return line and line.startswith("setuptools") and "post" in line
200-
201-
202-
def main():
203-
"""
204-
Run `detect_environment` and dump the result as JSON.
205-
"""
206-
try:
207-
if len(sys.argv) < 2:
208-
raise EnvironmentException("Usage: %s [-fc] DIRECTORY" % sys.argv[0])
209-
# directory is always the last argument
210-
directory = sys.argv[len(sys.argv) - 1]
211-
flags = ""
212-
force_generate = False
213-
if len(sys.argv) > 2:
214-
flags = sys.argv[1]
215-
if "f" in flags:
216-
force_generate = True
217-
envinfo = detect_environment(directory, force_generate)._asdict()
218-
if "contents" in envinfo:
219-
keepers = list(map(strip_ref, envinfo["contents"].split("\n")))
220-
keepers = [line for line in keepers if not exclude(line)]
221-
envinfo["contents"] = "\n".join(keepers)
222-
223-
json.dump(
224-
envinfo,
225-
sys.stdout,
226-
indent=4,
227-
)
228-
except EnvironmentException as exception:
229-
json.dump(dict(error=str(exception)), sys.stdout, indent=4)
230-
23112

232-
if __name__ == "__main__":
233-
main()
13+
def __init__(self, data: EnvironmentData, python_version_requirement: typing.Optional[str] = None):
14+
self._data = data
15+
self._data_fields = dataclasses.fields(self.data)
16+
17+
# Fields that are not loaded from the environment subprocess
18+
self.python_version_requirement
19+
20+
def __getattr__(self, name: str) -> typing.Any:
21+
# We directly proxy the attributes of the EnvironmentData object
22+
# so that schema changes can be handled in EnvironmentData exclusively.
23+
return self._data[name]
24+
25+
def __setattr__(self, name, value):
26+
if name in self._data_fields:
27+
# proxy the attribute to the underlying EnvironmentData object
28+
self._data._replace(name=value)
29+
else:
30+
super().__setattr__(name, value)
31+
32+
@classmethod
33+
def from_json(cls, json_data: dict) -> "Environment":
34+
"""Create an Environment instance from the JSON representation of EnvironmentData."""
35+
return cls(_MakeEnvironmentData(**json_data))

0 commit comments

Comments
 (0)