/
_node.py
408 lines (330 loc) · 11.8 KB
/
_node.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
from __future__ import annotations
import re
import os
import sys
import shutil
import logging
import subprocess
from pathlib import Path
from abc import ABC, abstractmethod
from typing import IO, Union, Any, Mapping, cast
from typing_extensions import Literal
from .. import config
from .._proxy import LazyProxy
from ..binaries import platform
from ..errors import PrismaError
from .._compat import nodejs, get_args
log: logging.Logger = logging.getLogger(__name__)
File = Union[int, IO[Any]]
Target = Literal['node', 'npm']
# taken from https://github.com/prisma/prisma/blob/main/package.json
MIN_NODE_VERSION = (14, 17)
# mapped the node version above from https://nodejs.org/en/download/releases/
MIN_NPM_VERSION = (6, 14)
# we only care about the first two entries in the version number
VERSION_RE = re.compile(r'v?(\d+)(?:\.?(\d+))')
# TODO: remove the possibility to get mismatched paths for `node` and `npm`
class UnknownTargetError(PrismaError):
def __init__(self, *, target: str) -> None:
super().__init__(
f'Unknown target: {target}; Valid choices are: {", ".join(get_args(cast(type, Target)))}'
)
# TODO: add tests for this error
class MissingNodejsBinError(PrismaError):
def __init__(self) -> None:
super().__init__(
'Attempted to access a function that requires the `nodejs-bin` package to be installed but it is not.'
)
class Strategy(ABC):
# TODO: support more options
def run(
self,
*args: str,
check: bool = False,
cwd: Path | None = None,
stdout: File | None = None,
stderr: File | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[bytes]:
"""Call the underlying Node.js binary.
The interface for this function is very similar to `subprocess.run()`.
"""
return self.__run__(
*args,
check=check,
cwd=cwd,
stdout=stdout,
stderr=stderr,
env=_update_path_env(
env=env,
target_bin=self.target_bin,
),
)
@abstractmethod
def __run__(
self,
*args: str,
check: bool = False,
cwd: Path | None = None,
stdout: File | None = None,
stderr: File | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[bytes]:
"""Call the underlying Node.js binary.
This should not be directly accessed, the `run()` function should be used instead.
"""
@property
@abstractmethod
def target_bin(self) -> Path:
"""Property containing the location of the `bin` directory for the resolved node installation.
This is used to dynamically alter the `PATH` environment variable to give the appearance that Node
is installed globally on the machine as this is a requirement of Prisma's installation step, see this
comment for more context: https://github.com/RobertCraigie/prisma-client-py/pull/454#issuecomment-1280059779
"""
...
class NodeBinaryStrategy(Strategy):
target: Target
resolver: Literal['global', 'nodeenv']
def __init__(
self,
*,
path: Path,
target: Target,
resolver: Literal['global', 'nodeenv'],
) -> None:
self.path = path
self.target = target
self.resolver = resolver
@property
def target_bin(self) -> Path:
return self.path.parent
def __run__(
self,
*args: str,
check: bool = False,
cwd: Path | None = None,
stdout: File | None = None,
stderr: File | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[bytes]:
path = str(self.path.absolute())
log.debug('Executing binary at %s with args: %s', path, args)
return subprocess.run(
[path, *args],
check=check,
cwd=cwd,
env=env,
stdout=stdout,
stderr=stderr,
)
@classmethod
def resolve(cls, target: Target) -> NodeBinaryStrategy:
path = None
if config.use_global_node:
path = _get_global_binary(target)
if path is not None:
return NodeBinaryStrategy(
path=path,
target=target,
resolver='global',
)
return NodeBinaryStrategy.from_nodeenv(target)
@classmethod
def from_nodeenv(cls, target: Target) -> NodeBinaryStrategy:
cache_dir = config.nodeenv_cache_dir.absolute()
if cache_dir.exists():
log.debug(
'Skipping nodeenv installation as it already exists at %s',
cache_dir,
)
else:
log.debug('Installing nodeenv to %s', cache_dir)
try:
subprocess.run(
[
sys.executable,
'-m',
'nodeenv',
str(cache_dir),
*config.nodeenv_extra_args,
],
check=True,
stdout=sys.stdout,
stderr=sys.stderr,
)
except Exception as exc:
print(
'nodeenv installation failed; You may want to try installing `nodejs-bin` as it is more reliable.',
file=sys.stderr,
)
raise exc
if not cache_dir.exists():
raise RuntimeError(
'Could not install nodeenv to the expected directory; See the output above for more details.'
)
# TODO: what hapens on cygwin?
if platform.name() == 'windows':
bin_dir = cache_dir / 'Scripts'
if target == 'node':
path = bin_dir / 'node.exe'
else:
path = bin_dir / f'{target}.cmd'
else:
path = cache_dir / 'bin' / target
if target == 'npm':
return cls(path=path, resolver='nodeenv', target=target)
elif target == 'node':
return cls(path=path, resolver='nodeenv', target=target)
else:
raise UnknownTargetError(target=target)
class NodeJSPythonStrategy(Strategy):
target: Target
resolver: Literal['nodejs-bin']
def __init__(self, *, target: Target) -> None:
self.target = target
self.resolver = 'nodejs-bin'
def __run__(
self,
*args: str,
check: bool = False,
cwd: Path | None = None,
stdout: File | None = None,
stderr: File | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[bytes]:
if nodejs is None:
raise MissingNodejsBinError()
func = None
if self.target == 'node':
func = nodejs.node.run
elif self.target == 'npm':
func = nodejs.npm.run
else:
raise UnknownTargetError(target=self.target)
return cast(
'subprocess.CompletedProcess[bytes]',
func(
args,
check=check,
cwd=cwd,
env=env,
stdout=stdout,
stderr=stderr,
),
)
@property
def node_path(self) -> Path:
"""Returns the path to the `node` binary"""
if nodejs is None:
raise MissingNodejsBinError()
return Path(nodejs.node.path)
@property
def target_bin(self) -> Path:
return Path(self.node_path).parent
Node = Union[NodeJSPythonStrategy, NodeBinaryStrategy]
def resolve(target: Target) -> Node:
if target not in {'node', 'npm'}:
raise UnknownTargetError(target=target)
if config.use_nodejs_bin:
log.debug('Checking if nodejs-bin is installed')
if nodejs is not None:
log.debug('Using nodejs-bin with version: %s', nodejs.node_version)
return NodeJSPythonStrategy(target=target)
return NodeBinaryStrategy.resolve(target)
def _update_path_env(
*,
env: Mapping[str, str] | None,
target_bin: Path,
sep: str = os.pathsep,
) -> dict[str, str]:
"""Returns a modified version of `os.environ` with the `PATH` environment variable updated
to include the location of the downloaded Node binaries.
"""
if env is None:
env = dict(os.environ)
log.debug('Attempting to preprend %s to the PATH', target_bin)
assert target_bin.exists(), 'Target `bin` directory does not exist'
path = env.get('PATH', '') or os.environ.get('PATH', '')
if path:
# handle the case where the PATH already starts with the separator (this probably shouldn't happen)
if path.startswith(sep):
path = f'{target_bin.absolute()}{path}'
else:
path = f'{target_bin.absolute()}{sep}{path}'
else:
# handle the case where there is no PATH set (unlikely / impossible to actually happen?)
path = str(target_bin.absolute())
log.debug('Using PATH environment variable: %s', path)
return {**env, 'PATH': path}
def _get_global_binary(target: Target) -> Path | None:
"""Returns the path to a globally installed binary.
This also ensures that the binary is of the right version.
"""
log.debug('Checking for global target binary: %s', target)
which = shutil.which(target)
if which is None:
log.debug('Global target binary: %s not found', target)
return None
log.debug('Found global binary at: %s', which)
path = Path(which)
if not path.exists():
log.debug('Global binary does not exist at: %s', which)
return None
if not _should_use_binary(target=target, path=path):
return None
log.debug('Using global %s binary at %s', target, path)
return path
def _should_use_binary(target: Target, path: Path) -> bool:
"""Call the binary at `path` with a `--version` flag to check if it matches our minimum version requirements.
This only applies to the global node installation as:
- the minimum version of `nodejs-bin` is higher than our requirement
- `nodeenv` defaults to the latest stable version of node
"""
if target == 'node':
min_version = MIN_NODE_VERSION
elif target == 'npm':
min_version = MIN_NPM_VERSION
else:
raise UnknownTargetError(target=target)
version = _get_binary_version(target, path)
if version is None:
log.debug(
'Could not resolve %s version, ignoring global %s installation',
target,
target,
)
return False
if version < min_version:
log.debug(
'Global %s version (%s) is lower than the minimum required version (%s), ignoring',
target,
version,
min_version,
)
return False
return True
def _get_binary_version(target: Target, path: Path) -> tuple[int, ...] | None:
proc = subprocess.run(
[str(path), '--version'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
log.debug('%s version check exited with code %s', target, proc.returncode)
output = proc.stdout.decode('utf-8').rstrip('\n')
log.debug('%s version check output: %s', target, output)
match = VERSION_RE.search(output)
if not match:
return None
version = tuple(int(value) for value in match.groups())
log.debug('%s version check returning %s', target, version)
return version
class LazyBinaryProxy(LazyProxy[Node]):
target: Target
def __init__(self, target: Target) -> None:
super().__init__()
self.target = target
def __load__(self) -> Node:
return resolve(self.target)
npm = LazyBinaryProxy('npm').__as_proxied__()
node = LazyBinaryProxy('node').__as_proxied__()