|
1 | | -#!/usr/bin/env python |
2 | | -""" |
3 | | -Environment data class abstraction that is usable as an executable module |
| 1 | +import typing |
| 2 | +import dataclasses |
4 | 3 |
|
5 | | -```bash |
6 | | -python -m rsconnect.environment |
7 | | -``` |
8 | | -""" |
9 | | -from __future__ import annotations |
| 4 | +from .subprocesses.environment import EnvironmentData, MakeEnvironmentData as _MakeEnvironmentData |
10 | 5 |
|
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 |
20 | 6 |
|
21 | | -version_re = re.compile(r"\d+\.\d+(\.\d+)?") |
22 | | -exec_dir = os.path.dirname(sys.executable) |
23 | | - |
24 | | - |
25 | | -@dataclass(frozen=True) |
26 | 7 | 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, |
120 | 9 |
|
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 |
124 | 11 | """ |
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 | | - |
231 | 12 |
|
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