venvpool 16__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.
venvpool/__init__.py ADDED
@@ -0,0 +1,868 @@
1
+ import os, sys
2
+
3
+ class MainModule:
4
+
5
+ letter = None
6
+
7
+ def __init__(self):
8
+ args = sys.argv
9
+ if 1 < len(args):
10
+ a = args[1]
11
+ if 1 < len(a) and '-' == a[0]:
12
+ self.letter = a[1]
13
+
14
+ def shortcut(self, cls):
15
+ if self.letter == cls.letter:
16
+ sys.exit(cls.main())
17
+
18
+ def end(self):
19
+ Activate.main()
20
+
21
+ if ('__main__' == __name__): # Hide from scriptregex.
22
+ _module = MainModule
23
+ else:
24
+ class _module:
25
+ def shortcut(self, cls):
26
+ pass
27
+ def end(self):
28
+ pass
29
+ _module = _module()
30
+ propersubcommands = []
31
+
32
+ def _shortcut(cls):
33
+ _module.shortcut(cls)
34
+ propersubcommands.append(cls)
35
+ return cls
36
+
37
+ def main():
38
+ m = MainModule()
39
+ for cls in propersubcommands:
40
+ m.shortcut(cls)
41
+ m.end()
42
+
43
+ def _decompress(paths):
44
+ i = iter(paths)
45
+ prefix = next(i)
46
+ try:
47
+ while True:
48
+ yield prefix + next(i)
49
+ except StopIteration:
50
+ pass
51
+
52
+ @_shortcut
53
+ class Execute:
54
+
55
+ help = 'augment interpreter environment and exec module for internal use only'
56
+ letter = 'X'
57
+
58
+ @classmethod
59
+ def main(cls):
60
+ import runpy # A few thousandths.
61
+ assert '-X' == sys.argv.pop(1)
62
+ sys.path[0] = bindir = os.path.dirname(sys.executable)
63
+ try:
64
+ envpath = os.environ['PATH']
65
+ except KeyError:
66
+ envpath = bindir
67
+ else:
68
+ envpath = bindir + os.pathsep + envpath
69
+ os.environ['PATH'] = envpath
70
+ sys.path[slice(*[cls._insertionpoint(sys.path)] * 2)] = _decompress(sys.argv.pop(1).split(os.pathsep))
71
+ exec(sys.argv.pop(1))
72
+ runpy.run_module(sys.argv.pop(1), run_name = '__main__', alter_sys = True)
73
+
74
+ @staticmethod
75
+ def _insertionpoint(v, suffix = os.sep + 'site-packages'):
76
+ i = n = len(v)
77
+ while not v[i - 1].endswith(suffix):
78
+ i -= 1
79
+ if not i:
80
+ return n
81
+ while i and v[i - 1].endswith(suffix):
82
+ i -= 1
83
+ return i
84
+
85
+ from collections import OrderedDict
86
+ from contextlib import contextmanager
87
+ from random import shuffle # XXX: Expensive?
88
+ from stat import S_IXUSR, S_IXGRP, S_IXOTH
89
+ from tempfile import mkdtemp, mkstemp
90
+ import errno, logging, operator, re, shutil # XXX: Still expensive?
91
+
92
+ class subprocess:
93
+
94
+ def __getattr__(self, name):
95
+ import subprocess # Up to a hundredth.
96
+ return getattr(subprocess, name)
97
+
98
+ log = logging.getLogger(__name__)
99
+ chainrelpath = os.path.join('venvpool', 'chain.py')
100
+ dotpy = '.py'
101
+ 'Python source file extension including dot.'
102
+ executablebits = S_IXUSR | S_IXGRP | S_IXOTH
103
+ oserrors = {code: type(name, (OSError,), {}) for code, name in errno.errorcode.items()}
104
+ pooldir = os.path.join(os.environ.get('XDG_CACHE_HOME') or os.path.join(os.path.expanduser('~'), '.cache'), 'venvpool')
105
+ scriptregex, = (r"^if\s+(?:__name__\s*==\s*{main}|{main}\s*==\s*__name__)\s*:\s*$".format(**locals()) for main in ['''(?:'__main__'|"__main__")'''])
106
+ try:
107
+ set_inheritable = os.set_inheritable
108
+ except AttributeError:
109
+ from fcntl import fcntl, FD_CLOEXEC, F_GETFD, F_SETFD
110
+ def set_inheritable(h, inherit):
111
+ assert inherit
112
+ fcntl(h, F_SETFD, fcntl(h, F_GETFD) & ~FD_CLOEXEC)
113
+ subprocess = subprocess()
114
+ userbin = os.path.join(os.path.expanduser('~'), '.local', 'bin')
115
+
116
+ def _osop(f, *args, **kwargs):
117
+ try:
118
+ return f(*args, **kwargs)
119
+ except OSError as e:
120
+ raise oserrors[e.errno](*e.args)
121
+
122
+ @contextmanager
123
+ def TemporaryDirectory():
124
+ tempdir = mkdtemp()
125
+ try:
126
+ yield tempdir
127
+ finally:
128
+ shutil.rmtree(tempdir)
129
+
130
+ @contextmanager
131
+ def _onerror(f):
132
+ try:
133
+ yield
134
+ except:
135
+ f()
136
+ raise
137
+
138
+ class Pip:
139
+
140
+ envpatch = dict(PYTHON_KEYRING_BACKEND = 'keyring.backends.null.Keyring')
141
+
142
+ def __init__(self, pippath):
143
+ self.pippath = pippath
144
+
145
+ def pipinstall(self, command):
146
+ subprocess.check_call([self.pippath, 'install'] + command, env = dict(os.environ, **self.envpatch), stdout = sys.stderr)
147
+
148
+ def listorempty(d, xform = lambda p: p):
149
+ try:
150
+ names = _osop(os.listdir, d)
151
+ except oserrors[errno.ENOENT]:
152
+ return []
153
+ return [xform(os.path.join(d, n)) for n in sorted(names)]
154
+
155
+ class LockStateException(Exception): pass
156
+
157
+ class ReadLock:
158
+
159
+ def __init__(self, handle):
160
+ self.handle = handle
161
+
162
+ def unlock(self):
163
+ try:
164
+ _osop(os.close, self.handle)
165
+ except oserrors[errno.EBADF]:
166
+ raise LockStateException
167
+
168
+ def _idempotentunlink(path):
169
+ try:
170
+ _osop(os.remove, path)
171
+ return True
172
+ except oserrors[errno.ENOENT]:
173
+ pass
174
+
175
+ def _chunkify(n, v):
176
+ i = iter(v)
177
+ while True:
178
+ chunk = []
179
+ for _ in range(n):
180
+ try:
181
+ x = next(i)
182
+ except StopIteration:
183
+ if chunk:
184
+ yield chunk
185
+ return
186
+ chunk.append(x)
187
+ yield chunk
188
+
189
+ if '/' == os.sep:
190
+ def _swept(readlocks):
191
+ for chunk in _chunkify(1000, readlocks):
192
+ # Check stderr instead of returncode for errors:
193
+ stdout, stderr = subprocess.Popen(['lsof', '-t'] + chunk, stdout = subprocess.PIPE, stderr = subprocess.PIPE).communicate()
194
+ if not stderr and not stdout:
195
+ for readlock in chunk:
196
+ if _idempotentunlink(readlock):
197
+ yield readlock
198
+ else:
199
+ def _swept(readlocks): # TODO: Untested!
200
+ for readlock in readlocks:
201
+ try:
202
+ if _idempotentunlink(readlock):
203
+ yield readlock
204
+ except oserrors[errno.EACCES]:
205
+ pass
206
+
207
+ def _compress(paths, splitchar = os.sep):
208
+ if not paths:
209
+ yield '-' # Avoid empty word in command line.
210
+ return
211
+ firstdiff = -1
212
+ for firstdiff, chars in enumerate(zip(*paths)):
213
+ if 1 != len(set(chars)):
214
+ break
215
+ else:
216
+ firstdiff += 1
217
+ while firstdiff and paths[0][firstdiff - 1] != splitchar:
218
+ firstdiff -= 1
219
+ yield paths[0][:firstdiff]
220
+ for p in paths:
221
+ yield p[firstdiff:]
222
+
223
+ class SharedDir(object):
224
+
225
+ def __init__(self, dirpath):
226
+ self.readlocks = os.path.join(dirpath, 'readlocks')
227
+
228
+ def _sweep(self):
229
+ paths = list(_compress(list(_swept(listorempty(self.readlocks)))))
230
+ n = len(paths)
231
+ if 2 == n:
232
+ log.debug("Swept: %s%s", *paths)
233
+ elif 2 < n:
234
+ log.debug("Swept: %s{%s}", paths[0], ','.join(paths[1:]))
235
+
236
+ def trywritelock(self):
237
+ self._sweep()
238
+ try:
239
+ _osop(os.rmdir, self.readlocks)
240
+ return True
241
+ except (oserrors[errno.ENOENT], oserrors[errno.ENOTEMPTY]):
242
+ pass
243
+
244
+ def createortrywritelock(self):
245
+ try:
246
+ _osop(os.mkdir, os.path.dirname(self.readlocks))
247
+ return True
248
+ except oserrors[errno.EEXIST]:
249
+ return self.trywritelock()
250
+
251
+ def writeunlock(self):
252
+ try:
253
+ _osop(os.mkdir, self.readlocks)
254
+ except oserrors[errno.EEXIST]:
255
+ raise LockStateException
256
+
257
+ def tryreadlock(self):
258
+ try:
259
+ h = _osop(mkstemp, dir = self.readlocks, prefix = 'lock')[0]
260
+ set_inheritable(h, True)
261
+ return ReadLock(h)
262
+ except oserrors[errno.ENOENT]:
263
+ pass
264
+
265
+ def safe_name(name):
266
+ return re.sub('[^A-Za-z0-9.]+', '-', name)
267
+
268
+ def to_filename(name):
269
+ return name.replace('-', '_')
270
+
271
+ class Venv(SharedDir):
272
+
273
+ @staticmethod
274
+ def _safewhich(name):
275
+ poolprefix = pooldir + os.sep
276
+ for bindir in os.environ['PATH'].split(os.pathsep):
277
+ if bindir.startswith(poolprefix) or not os.path.isabs(bindir): # XXX: Also exclude other venvs?
278
+ log.debug("Ignore bin directory: %s", bindir)
279
+ else:
280
+ path = os.path.join(bindir, name)
281
+ if os.path.exists(path):
282
+ return path
283
+
284
+ @property
285
+ def site_packages(self):
286
+ libpath = os.path.join(self.venvpath, 'lib')
287
+ pyname, = (n for n in os.listdir(libpath) if n.startswith('python'))
288
+ return os.path.join(libpath, pyname, 'site-packages')
289
+
290
+ def __init__(self, venvpath):
291
+ super(Venv, self).__init__(venvpath)
292
+ self.venvpath = venvpath
293
+
294
+ def create(self, pyversion):
295
+ def isolated(*command):
296
+ subprocess.check_call(command, cwd = tempdir, stdout = sys.stderr)
297
+ executable = self._safewhich("python%s" % pyversion)
298
+ absvenvpath = os.path.abspath(self.venvpath) # XXX: Safe when venvpath has symlinks?
299
+ with TemporaryDirectory() as tempdir:
300
+ if pyversion < 3:
301
+ isolated('virtualenv', '-p', executable, absvenvpath)
302
+ else:
303
+ isolated(executable, '-m', 'venv', absvenvpath)
304
+ isolated(os.path.join(absvenvpath, 'bin', 'pip'), 'install', '--upgrade', 'pip', 'setuptools', 'wheel')
305
+ chainpath = os.path.join(self.site_packages, chainrelpath)
306
+ os.mkdir(os.path.dirname(chainpath))
307
+ with open(chainpath, 'w') as f:
308
+ f.write('''import os, sys
309
+ v = [os.path.join(os.path.dirname(sys.executable), sys.argv[1])] + sys.argv[2:]
310
+ os.execv(v[0], v)
311
+ ''')
312
+
313
+ def delete(self, label = 'transient'):
314
+ log.debug("Delete %s venv: %s", label, self.venvpath)
315
+ shutil.rmtree(self.venvpath)
316
+
317
+ def programpath(self, name):
318
+ return os.path.join(self.venvpath, 'bin', name)
319
+
320
+ def install(self, args):
321
+ log.debug("Install: %s", ' '.join(args))
322
+ if args:
323
+ Pip(self.programpath('pip')).pipinstall(args)
324
+
325
+ def compatible(self, installdeps):
326
+ for r in installdeps.pypireqs:
327
+ version = self._reqversionornone(r.namepart)
328
+ if version is None or not r.acceptversion(version):
329
+ return
330
+ log.debug("Found compatible venv: %s", self.venvpath)
331
+ return True
332
+
333
+ def _reqversionornone(self, name):
334
+ patterns = [re.compile(format % nameregex) for nameregex in [re.escape(to_filename(safe_name(name)).lower())] for format in [
335
+ "^%s-(.+)[.]dist-info$",
336
+ "^%s-([^-]+).*[.]egg-info$"]]
337
+ for lowername in (n.lower() for n in os.listdir(self.site_packages)):
338
+ for p in patterns:
339
+ m = p.search(lowername)
340
+ if m is not None:
341
+ return m.group(1)
342
+
343
+ def run(self, mode, localreqs, task, scriptargs, **kwargs):
344
+ try:
345
+ patch = task.patch
346
+ module = task.module
347
+ except AttributeError:
348
+ patch = '#'
349
+ module = task
350
+ argv = [os.path.join(self.venvpath, 'bin', 'python'), _stripc(__file__), '-X', os.pathsep.join(_compress(localreqs)), patch, module] + scriptargs
351
+ if 'call' == mode:
352
+ return subprocess.call(argv, **kwargs)
353
+ if 'check_call' == mode:
354
+ return subprocess.check_call(argv, **kwargs)
355
+ if 'check_output' == mode:
356
+ return subprocess.check_output(argv, **kwargs)
357
+ if 'exec' == mode:
358
+ os.execv(argv[0], argv, **kwargs)
359
+ raise ValueError(mode)
360
+
361
+ class Task:
362
+
363
+ def __init__(self, patch, module):
364
+ self.patch = patch
365
+ self.module = module
366
+
367
+ def _stripc(path):
368
+ return path[:-1] if 'c' == path[-1] else path
369
+
370
+ class Pool:
371
+
372
+ @property
373
+ def versiondir(self):
374
+ return os.path.join(pooldir, str(self.pyversion))
375
+
376
+ def __init__(self, pyversion):
377
+ self.readonlyortransient = {
378
+ False: self.readonly,
379
+ True: self._transient,
380
+ }
381
+ self.readonlyorreadwrite = {
382
+ False: self.readonly,
383
+ True: self.readwrite,
384
+ }
385
+ self.pyversion = pyversion
386
+
387
+ def _newvenv(self, installdeps):
388
+ log.info('Create new venv.')
389
+ try:
390
+ _osop(os.makedirs, self.versiondir)
391
+ except oserrors[errno.EEXIST]:
392
+ pass
393
+ venv = Venv(mkdtemp(dir = self.versiondir, prefix = 'venv'))
394
+ with _onerror(venv.delete):
395
+ venv.create(self.pyversion)
396
+ installdeps.invoke(venv)
397
+ assert venv.compatible(installdeps) # Bug if not.
398
+ return venv
399
+
400
+ def _lockcompatiblevenv(self, trylock, installdeps):
401
+ venvs = listorempty(self.versiondir, Venv)
402
+ shuffle(venvs)
403
+ for venv in venvs:
404
+ lock = trylock(venv)
405
+ if lock is not None:
406
+ with _onerror(lock.unlock):
407
+ if venv.compatible(installdeps): # TODO: Upgrade venv if it has a subset.
408
+ return venv, lock
409
+ lock.unlock()
410
+
411
+ @contextmanager
412
+ def _transient(self, installdeps):
413
+ venv = self._newvenv(installdeps)
414
+ try:
415
+ yield venv
416
+ finally:
417
+ venv.delete()
418
+
419
+ @contextmanager
420
+ def readonly(self, installdeps):
421
+ while True:
422
+ t = self._lockcompatiblevenv(Venv.tryreadlock, installdeps)
423
+ if t is not None:
424
+ venv, readlock = t
425
+ break
426
+ venv = self._newvenv(installdeps)
427
+ # XXX: Would it be possible to atomically convert write lock to read lock?
428
+ venv.writeunlock()
429
+ readlock = venv.tryreadlock()
430
+ if readlock is not None:
431
+ break
432
+ try:
433
+ yield venv
434
+ finally:
435
+ readlock.unlock()
436
+
437
+ @contextmanager
438
+ def readwrite(self, installdeps):
439
+ def trywritelock(venv):
440
+ if venv.trywritelock():
441
+ class WriteLock:
442
+ def unlock(self):
443
+ venv.writeunlock()
444
+ return WriteLock()
445
+ t = self._lockcompatiblevenv(trywritelock, installdeps)
446
+ if t is None:
447
+ venv = self._newvenv(installdeps)
448
+ else:
449
+ venv = t[0]
450
+ with _onerror(venv.writeunlock):
451
+ for dirpath, dirnames, filenames in os.walk(venv.venvpath):
452
+ for name in filenames:
453
+ p = os.path.join(dirpath, name)
454
+ if 1 != os.stat(p).st_nlink:
455
+ h, q = mkstemp(dir = dirpath)
456
+ os.close(h)
457
+ shutil.copy2(p, q)
458
+ os.remove(p) # Cross-platform.
459
+ os.rename(q, p)
460
+ try:
461
+ yield venv
462
+ finally:
463
+ venv.writeunlock()
464
+
465
+ class FastReq:
466
+
467
+ class Version:
468
+
469
+ def __init__(self, operator, splitversion):
470
+ self.operator = operator
471
+ self.splitversion = splitversion
472
+
473
+ def accept(self, splitversion):
474
+ def pad(v):
475
+ return v + [0] * (n - len(v))
476
+ versions = [splitversion, self.splitversion]
477
+ n = max(map(len, versions))
478
+ return self.operator(*map(pad, versions))
479
+
480
+ class DevSegment:
481
+
482
+ def __init__(self, n):
483
+ self.n = n
484
+
485
+ @classmethod
486
+ def _splitversion(cls, versionstr, devprefix = 'dev'):
487
+ return [cls.DevSegment(int(k[len(devprefix):])) if k.startswith(devprefix) else int(k) for k in versionstr.split('.')]
488
+
489
+ s = r'\s*'
490
+ nameregex = '[A-Za-z0-9._-]+' # Slightly more lenient than PEP 508.
491
+ extras = r"\[{s}(?:{nameregex}{s}(?:,{s}{nameregex}{s})*)?]".format(**locals())
492
+ version = "(<|<=|!=|==|>=|>){s}([0-9.]+(?:[.]dev[0-9]+)?)".format(**locals()) # Subset of features.
493
+ versionregex = "^{s}{version}{s}$".format(**locals())
494
+ getregex = "^{s}({nameregex}){s}({extras}{s})?({version}{s}(?:,{s}{version}{s})*)?$".format(**locals())
495
+ skipregex = "^{s}(?:#|$)".format(**locals())
496
+ del s, extras, version
497
+ operators = {
498
+ '<': operator.lt,
499
+ '<=': operator.le,
500
+ '!=': operator.ne,
501
+ '==': operator.eq,
502
+ '>=': operator.ge,
503
+ '>': operator.gt,
504
+ }
505
+
506
+ @classmethod
507
+ def parselines(cls, lines):
508
+ def g():
509
+ for line in lines:
510
+ if re.search(cls.skipregex, line) is not None:
511
+ continue
512
+ namepart, extras, versionspec = re.search(cls.getregex, line).groups()[:3]
513
+ extras = () if extras is None else tuple(sorted(set(re.findall(cls.nameregex, extras)))) # TODO LATER: Normalisation.
514
+ versions = []
515
+ reqstrversions = []
516
+ if versionspec is not None:
517
+ for onestr in versionspec.split(','):
518
+ operatorstr, versionstr = re.search(cls.versionregex, onestr).groups()
519
+ versions.append(cls.Version(cls.operators[operatorstr], cls._splitversion(versionstr)))
520
+ reqstrversions.append(operatorstr + versionstr)
521
+ yield cls(namepart, extras, versions, namepart + ("[%s]" % ','.join(extras) if extras else '') + ','.join(sorted(reqstrversions)))
522
+ return list(g())
523
+
524
+ def __init__(self, namepart, extras, versions, reqstr):
525
+ self.namepart = namepart
526
+ self.extras = extras
527
+ self.versions = versions
528
+ self.reqstr = reqstr
529
+
530
+ def acceptversion(self, versionstr):
531
+ splitversion = self._splitversion(versionstr)
532
+ return all(v.accept(splitversion) for v in self.versions)
533
+
534
+ class ParsedRequires:
535
+
536
+ parselines = staticmethod(FastReq.parselines)
537
+
538
+ def __init__(self, requires):
539
+ self.pypireqs = self.parselines(requires)
540
+
541
+ def invoke(self, venv):
542
+ venv.install([r.reqstr for r in self.pypireqs])
543
+
544
+ def poplocalreqs(self, workspace):
545
+ projectdirs = {}
546
+ for projectdir in (os.path.join(workspace, n) for n in os.listdir(workspace)):
547
+ if os.path.isdir(projectdir):
548
+ eggdirnames = [n for n in os.listdir(projectdir) if n.endswith('.egg-info')]
549
+ if 1 == len(eggdirnames):
550
+ with open(os.path.join(projectdir, eggdirnames[0], 'PKG-INFO'), encoding = 'utf-8') as f:
551
+ try:
552
+ parser
553
+ except NameError:
554
+ from email.parser import Parser
555
+ parser = Parser()
556
+ name = parser.parse(f)['Name']
557
+ if name is not None:
558
+ projectdirs[name] = projectdir
559
+ local = OrderedDict()
560
+ reqs = list(self.pypireqs)
561
+ del self.pypireqs[:]
562
+ while reqs:
563
+ nextreqs = []
564
+ for req in reqs:
565
+ projectdir = projectdirs.get(req.namepart)
566
+ if projectdir is None:
567
+ self.pypireqs.append(req)
568
+ elif projectdir not in local:
569
+ requirementslines = _getrequirementslinesornone(projectdir, req.extras)
570
+ if requirementslines is None:
571
+ raise NoRequirementsFoundException("%s[%s]" % (projectdir, ','.join(req.extras)))
572
+ nextreqs.extend(self.parselines(requirementslines))
573
+ local[projectdir] = None
574
+ reqs = nextreqs
575
+ return list(local)
576
+
577
+ def _getrequirementslinesornone(projectdir, extras):
578
+ def linesornone(acceptnull, *names):
579
+ path = os.path.join(projectdir, *names)
580
+ if os.path.exists(path):
581
+ log.debug("Found requirements: %s", path)
582
+ with open(path) as f:
583
+ return f.read().splitlines()
584
+ if acceptnull:
585
+ log.debug("Null requirements: %s", path)
586
+ return []
587
+ if extras:
588
+ log.warning("Ignore extras %s in: %s", ','.join(extras), projectdir) # TODO: Find extra requirements as well.
589
+ v = linesornone(False, 'requirements.txt')
590
+ if v is not None:
591
+ return v
592
+ names = [name for name in os.listdir(projectdir) if name.endswith('.egg-info')]
593
+ if names:
594
+ name, = names # XXX: Could there legitimately be multiple?
595
+ return linesornone(True, name, 'requires.txt')
596
+
597
+ def initlogging():
598
+ 'Initialise the logging module to send debug (and higher levels) to stderr.'
599
+ logging.basicConfig(format = "%(asctime)s %(levelname)s %(message)s", level = logging.DEBUG)
600
+
601
+ class ParserCommand:
602
+
603
+ @classmethod
604
+ def main(cls):
605
+ from argparse import ArgumentParser # Two or three hundredths.
606
+ initlogging()
607
+ parser = ArgumentParser(description = cls.help)
608
+ if cls.letter is not None:
609
+ parser.add_argument('-' + cls.letter, action = 'store_true', help = 'select this subcommand')
610
+ cls.initparser(parser)
611
+ parser.add_argument('-v', action = 'store_true', help = 'show debug logging')
612
+ args = parser.parse_args()
613
+ if cls.letter is not None:
614
+ assert getattr(args, cls.letter)
615
+ if not args.v:
616
+ logging.getLogger().setLevel(logging.INFO)
617
+ cls.mainimpl(args)
618
+
619
+ class NoRequirementsFoundException(Exception): pass
620
+
621
+ def _findrequirements(projectdir):
622
+ while True:
623
+ requirementslines = _getrequirementslinesornone(projectdir, ())
624
+ if requirementslines is not None:
625
+ return projectdir, requirementslines
626
+ parent = os.path.dirname(projectdir)
627
+ if parent == projectdir:
628
+ raise NoRequirementsFoundException
629
+ projectdir = parent
630
+
631
+ @_shortcut
632
+ class Launch(ParserCommand):
633
+
634
+ help = 'launch a script with the interpreter and requirements it needs'
635
+ letter = 'L'
636
+
637
+ @staticmethod
638
+ def initparser(parser):
639
+ parser.add_argument('--req', help = 'use the given requirement specifier only')
640
+ parser.add_argument('scriptpath', help = 'should be preceded by a -- arg')
641
+ parser.add_argument('scriptarg', nargs = '*', help = 'arguments for scriptpath')
642
+
643
+ @classmethod
644
+ def mainimpl(cls, args):
645
+ try:
646
+ dd = sys.argv.index('--')
647
+ except ValueError:
648
+ scriptpath = args.scriptpath
649
+ scriptargs = args.scriptarg
650
+ else:
651
+ scriptpath = sys.argv[dd + 1]
652
+ scriptargs = sys.argv[dd + 2:]
653
+ assert scriptpath.endswith(dotpy)
654
+ reqstr = args.req
655
+ if reqstr is None:
656
+ scriptpath = os.path.abspath(scriptpath) # XXX: Is abspath safe when scriptpath has symlinks?
657
+ projectdir, requirementslines = _findrequirements(os.path.dirname(scriptpath))
658
+ installdeps = ParsedRequires(requirementslines)
659
+ localreqs = installdeps.poplocalreqs(os.path.normpath(os.path.join(projectdir, '..')))
660
+ localreqs.insert(0, projectdir)
661
+ module = os.path.relpath(scriptpath[:-len(dotpy)], projectdir).replace(os.sep, '.')
662
+ else:
663
+ installdeps = ParsedRequires([reqstr])
664
+ localreqs = []
665
+ module = scriptpath[:-len(dotpy)].replace(os.sep, '.')
666
+ with Pool(sys.version_info.major).readonly(installdeps) as venv: # TODO: Likely to be major 2 when launching manually.
667
+ venv.run('exec', localreqs, module, scriptargs)
668
+
669
+ class Activate(ParserCommand):
670
+
671
+ help = 'create and maintain wrapper scripts'
672
+ letter = None
673
+
674
+ @staticmethod
675
+ def initparser(parser):
676
+ group = parser.add_mutually_exclusive_group()
677
+ for cls in propersubcommands:
678
+ group.add_argument('-' + cls.letter, action = 'store_true', help = 'subcommand to ' + cls.help)
679
+ parser.add_argument('--bin', help = 'custom scripts directory', default = userbin)
680
+ parser.add_argument('-f', action = 'store_true', help = 'overwrite existing scripts')
681
+ parser.add_argument('projectdir', nargs = '*', default = ['.'], help = 'projects to search for runnable modules')
682
+
683
+ @classmethod
684
+ def mainimpl(cls, args):
685
+ for projectdir in args.projectdir:
686
+ try:
687
+ cls._scan(_findrequirements(os.path.realpath(projectdir))[0], 3, args.f, args.bin) # XXX: Always 3?
688
+ except NoRequirementsFoundException:
689
+ log.exception("Skip: %s", projectdir)
690
+
691
+ @staticmethod
692
+ def _srcpaths(rootdir):
693
+ for dirpath, dirnames, filenames in os.walk(rootdir):
694
+ for name in filenames:
695
+ if name.endswith(dotpy):
696
+ path = os.path.join(dirpath, name)
697
+ with open(path) as f:
698
+ if re.search(scriptregex, f.read(), re.MULTILINE) is not None:
699
+ yield path
700
+
701
+ @classmethod
702
+ def _scan(cls, projectdir, pyversion, force, bindir):
703
+ for srcpath in cls._srcpaths(projectdir):
704
+ if not checkpath(projectdir, srcpath):
705
+ log.debug("Not a project source file: %s", srcpath)
706
+ continue
707
+ command = commandornone(srcpath)
708
+ if command is None:
709
+ log.debug("Bad source name: %s", srcpath)
710
+ continue
711
+ cls.install(force, command, pyversion, None, bindir, srcpath)
712
+
713
+ @staticmethod
714
+ def install(force, command, pyversion, reqstrornone, bindir, *words):
715
+ def allwords():
716
+ if reqstrornone is not None:
717
+ yield '--req'
718
+ yield reqstrornone
719
+ yield '--'
720
+ for w in words:
721
+ yield w
722
+ def identical():
723
+ mode = os.stat(scriptpath).st_mode
724
+ if mode | executablebits == mode:
725
+ with open(scriptpath) as f:
726
+ return f.read() == text
727
+ wordsrepr = ', '.join(map(repr, allwords()))
728
+ venvpoolpath = _stripc(os.path.realpath(__file__))
729
+ text = """#!/usr/bin/env python{pyversion}
730
+ import sys
731
+ sys.argv[1:1] = '-L', {wordsrepr}
732
+ __file__ = {venvpoolpath!r}
733
+ with open(__file__) as f: venvpoolsrc = f.read()
734
+ del sys, f
735
+ exec(venvpoolsrc)
736
+ """.format(**locals())
737
+ scriptpath = os.path.join(bindir, command) # TODO: Warn if shadowed.
738
+ if os.path.exists(scriptpath):
739
+ if identical():
740
+ log.debug("Identical: %s", scriptpath)
741
+ return
742
+ if not force:
743
+ log.info("Exists: %s", scriptpath)
744
+ return
745
+ log.info("Overwrite: %s", scriptpath)
746
+ else:
747
+ log.info("Create: %s", scriptpath)
748
+ with open(scriptpath, 'w') as f:
749
+ f.write(text)
750
+ os.chmod(scriptpath, os.stat(scriptpath).st_mode | executablebits)
751
+
752
+ def checkpath(projectdir, path):
753
+ while True:
754
+ path = os.path.dirname(path)
755
+ if path == projectdir:
756
+ return True
757
+ if not os.path.exists(os.path.join(path, '__init__.py')): # XXX: What about namespace packages?
758
+ break
759
+
760
+ def commandornone(srcpath):
761
+ name = os.path.basename(srcpath)
762
+ name = os.path.basename(os.path.dirname(srcpath)) if '__init__.py' == name else name[:-len(dotpy)]
763
+ if '-' not in name:
764
+ return name.replace('_', '-')
765
+
766
+ @_shortcut
767
+ class Compact(ParserCommand):
768
+
769
+ help = 'compact the pool of venvs'
770
+ letter = 'C'
771
+
772
+ @staticmethod
773
+ def initparser(parser):
774
+ pass
775
+
776
+ @classmethod
777
+ def mainimpl(cls, args): # XXX: Combine venvs with orthogonal dependencies?
778
+ venvtofreeze = {}
779
+ try:
780
+ for versiondir in listorempty(pooldir):
781
+ for venv in listorempty(versiondir, Venv):
782
+ if venv.trywritelock():
783
+ venvtofreeze[venv] = set(subprocess.check_output([venv.programpath('pip'), 'freeze'], universal_newlines = True).splitlines())
784
+ else:
785
+ log.debug("Busy: %s", venv.venvpath)
786
+ log.debug('Find redundant venvs.')
787
+ while True:
788
+ venv = cls._redundantvenv(venvtofreeze)
789
+ if venv is None:
790
+ break
791
+ venv.delete('redundant')
792
+ venvtofreeze.pop(venv)
793
+ cls._compactvenvs([l.venvpath for l in venvtofreeze])
794
+ finally:
795
+ for l in venvtofreeze:
796
+ l.writeunlock()
797
+
798
+ @staticmethod
799
+ def _redundantvenv(venvtofreeze):
800
+ for venv, freeze in venvtofreeze.items():
801
+ for othervenv, otherfreeze in venvtofreeze.items():
802
+ if venv != othervenv and os.path.dirname(venv.venvpath) == os.path.dirname(othervenv.venvpath) and freeze <= otherfreeze:
803
+ return venv
804
+
805
+ @staticmethod
806
+ def _compactvenvs(venvpaths):
807
+ log.info("Compact %s venvs.", len(venvpaths))
808
+ if venvpaths:
809
+ subprocess.check_call(['jdupes', '-Lrq'] + venvpaths)
810
+ log.info('Compaction complete.')
811
+
812
+ @_shortcut
813
+ class Unlock(ParserCommand):
814
+
815
+ help = 'release write locks on reboot'
816
+ letter = 'U'
817
+
818
+ @staticmethod
819
+ def initparser(parser):
820
+ pass
821
+
822
+ @classmethod
823
+ def mainimpl(cls, args):
824
+ for versiondir in listorempty(pooldir):
825
+ for venv in listorempty(versiondir, Venv):
826
+ try:
827
+ venv.writeunlock()
828
+ except LockStateException:
829
+ log.debug("Was not write locked: %s", venv.venvpath)
830
+ else:
831
+ log.warning("Released write lock: %s", venv.venvpath)
832
+
833
+ @_shortcut
834
+ class ConsoleScripts(ParserCommand):
835
+
836
+ help = 'activate all console scripts of the given requirement specifier'
837
+ letter = 'S'
838
+
839
+ @staticmethod
840
+ def initparser(parser):
841
+ parser.add_argument('-f', action = 'store_true', help = 'overwrite existing scripts')
842
+ parser.add_argument('spec', help = 'a requirement specifier')
843
+
844
+ @classmethod
845
+ def mainimpl(cls, args):
846
+ from inspect import getsource # About two hundredths.
847
+ spec, = FastReq.parselines([args.spec])
848
+ pyversion = 3
849
+ with TemporaryDirectory() as tempdir:
850
+ with open(os.path.join(tempdir, 'list.py'), 'w') as f:
851
+ f.write("class C:\n%sC.%s(%r)\n" % (getsource(cls._commands), cls._commands.__name__, spec.namepart))
852
+ with Pool(pyversion).readonly(ParsedRequires(['importlib-metadata', spec.reqstr])) as venv:
853
+ names = set(venv.run('check_output', [tempdir], 'list', [], universal_newlines = True).splitlines())
854
+ for name in names:
855
+ Activate.install(args.f, name, pyversion, spec.reqstr, userbin, chainrelpath, name)
856
+
857
+ @staticmethod
858
+ def _commands(distname):
859
+ from importlib_metadata import distribution, files
860
+ for ep in distribution(distname).entry_points:
861
+ if 'console_scripts' == ep.group:
862
+ print(ep.name)
863
+ for p in files(distname):
864
+ t = p.parts
865
+ if 5 == len(t) and ('..', '..', '..', 'bin') == t[:4]:
866
+ print(t[4])
867
+
868
+ _module.end()
venvpool/motivate.py ADDED
@@ -0,0 +1,5 @@
1
+ 'Create and maintain wrapper scripts in ~/.local/bin for all runnable modules in the given projects, or the current project if none given.'
2
+ from . import main
3
+
4
+ if '__main__' == __name__:
5
+ main()
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.1
2
+ Name: venvpool
3
+ Version: 16
4
+ Summary: Run your Python scripts using an automated pool of virtual environments to satisfy their requirements
5
+ Home-page: https://pypi.org/project/venvpool/
6
+ Author: foyono
7
+ Author-email: shrovis@foyono.com
8
+ Description-Content-Type: text/markdown
9
+
10
+ # venvpool
11
+ Run your Python scripts using an automated pool of virtual environments to satisfy their requirements
12
+
13
+ ## Commands
14
+
15
+ ### motivate
16
+ Create and maintain wrapper scripts in ~/.local/bin for all runnable modules in the given projects, or the current project if none given.
17
+
18
+ ### motivate -S
19
+ Create/maintain wrappers for all console_scripts of the given requirement specifier.
20
+
21
+ ### motivate -C
22
+ Compact the pool of venvs.
23
+
24
+ ## API
25
+
26
+ <a id="venvpool"></a>
27
+
28
+ ### venvpool
29
+
30
+ <a id="venvpool.dotpy"></a>
31
+
32
+ ###### dotpy
33
+
34
+ Python source file extension including dot.
35
+
36
+ <a id="venvpool.initlogging"></a>
37
+
38
+ ###### initlogging
39
+
40
+ ```python
41
+ def initlogging()
42
+ ```
43
+
44
+ Initialise the logging module to send debug (and higher levels) to stderr.
45
+
@@ -0,0 +1,7 @@
1
+ venvpool/__init__.py,sha256=W9H13HoAjpSpvgU_vvgl09NVhjJ53KTZvrnsbsB2bCU,30489
2
+ venvpool/motivate.py,sha256=ZLLQhnb1fC6-grrW7dNXeoFAnlpyB6tuNoEH6Z20_5s,198
3
+ venvpool-16.dist-info/METADATA,sha256=YDKhsmrw69cL0OdRrGq84h-tK3xOtPPRRpGGyrk0Dyc,1017
4
+ venvpool-16.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
5
+ venvpool-16.dist-info/entry_points.txt,sha256=dYx6DJExGkRlQyppF811suVhhV8oUBEVxmquF6luKno,52
6
+ venvpool-16.dist-info/top_level.txt,sha256=WRvBxvEyUmyq-Dqbja9QG1fJUgv2CaVFuyGLwYaCMbs,9
7
+ venvpool-16.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ motivate = venvpool.motivate:main
@@ -0,0 +1 @@
1
+ venvpool