doploy-cli 0.2.2__tar.gz → 0.2.5__tar.gz
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.
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/PKG-INFO +1 -1
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli/main.py +75 -22
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli.egg-info/PKG-INFO +1 -1
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/pyproject.toml +1 -1
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/LICENSE +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/README.md +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli/SKILL.md +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli/__init__.py +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli.egg-info/SOURCES.txt +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli.egg-info/dependency_links.txt +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli.egg-info/entry_points.txt +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli.egg-info/requires.txt +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/doploy_cli.egg-info/top_level.txt +0 -0
- {doploy_cli-0.2.2 → doploy_cli-0.2.5}/setup.cfg +0 -0
|
@@ -20,8 +20,14 @@ import webbrowser
|
|
|
20
20
|
import stat
|
|
21
21
|
import sys
|
|
22
22
|
import time
|
|
23
|
+
from contextlib import contextmanager
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
except ImportError: # Windows: no cross-process lock, refresh as before
|
|
29
|
+
fcntl = None
|
|
30
|
+
|
|
25
31
|
from typing import Optional
|
|
26
32
|
|
|
27
33
|
import httpx
|
|
@@ -39,9 +45,34 @@ def _load_config() -> dict:
|
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def _save_config(cfg: dict) -> None:
|
|
48
|
+
# Write-then-rename so a concurrent reader never sees a half-written file
|
|
49
|
+
# (it would parse as {} and report "not logged in").
|
|
50
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
tmp = CONFIG_PATH.with_name(f'{CONFIG_PATH.name}.{os.getpid()}.tmp')
|
|
52
|
+
tmp.unlink(missing_ok=True) # leftover from a crashed run
|
|
53
|
+
# Created 0600 (token inside) and O_EXCL: never readable by other users,
|
|
54
|
+
# never follows a planted symlink.
|
|
55
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
|
|
56
|
+
with os.fdopen(fd, 'w') as fh:
|
|
57
|
+
fh.write(json.dumps(cfg, indent=2) + '\n')
|
|
58
|
+
os.replace(tmp, CONFIG_PATH)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def _refresh_lock():
|
|
63
|
+
"""Serialize token refresh across doploy processes. The server rotates the
|
|
64
|
+
refresh token on every use and revokes the whole grant if an already-used
|
|
65
|
+
one is presented again, so two processes refreshing at once log you out."""
|
|
66
|
+
if fcntl is None:
|
|
67
|
+
yield
|
|
68
|
+
return
|
|
42
69
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
-
CONFIG_PATH.
|
|
44
|
-
|
|
70
|
+
with open(CONFIG_PATH.with_name(CONFIG_PATH.name + '.lock'), 'w') as fh:
|
|
71
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
45
76
|
|
|
46
77
|
|
|
47
78
|
class Client:
|
|
@@ -56,21 +87,30 @@ class Client:
|
|
|
56
87
|
def _refresh_access_token(self) -> bool:
|
|
57
88
|
if not self.refresh or not self.refresh.get('refresh_token'):
|
|
58
89
|
return False
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
with _refresh_lock():
|
|
91
|
+
# Another process may have refreshed while we waited for the lock:
|
|
92
|
+
# use its tokens instead of replaying our now-rotated refresh token.
|
|
93
|
+
cfg = _load_config()
|
|
94
|
+
if cfg.get('refresh_token') and cfg['refresh_token'] != self.refresh['refresh_token']:
|
|
95
|
+
self.token = cfg.get('access_token') or ''
|
|
96
|
+
self.refresh['refresh_token'] = cfg['refresh_token']
|
|
97
|
+
self.refresh['expires_at'] = cfg.get('expires_at', 0)
|
|
98
|
+
return True
|
|
99
|
+
r = _oauth_token_request(self.base, {
|
|
100
|
+
'grant_type': 'refresh_token',
|
|
101
|
+
'refresh_token': self.refresh['refresh_token'],
|
|
102
|
+
'client_id': self.refresh['client_id'],
|
|
103
|
+
})
|
|
104
|
+
if r is None:
|
|
105
|
+
return False
|
|
106
|
+
self.token = r['access_token']
|
|
107
|
+
self.refresh['refresh_token'] = r['refresh_token']
|
|
108
|
+
self.refresh['expires_at'] = time.time() + int(r.get('expires_in') or 3600)
|
|
109
|
+
cfg = _load_config()
|
|
110
|
+
cfg.update(access_token=self.token, refresh_token=self.refresh['refresh_token'],
|
|
111
|
+
expires_at=self.refresh['expires_at'])
|
|
112
|
+
_save_config(cfg)
|
|
113
|
+
return True
|
|
74
114
|
|
|
75
115
|
def request(self, method: str, path: str, **kw):
|
|
76
116
|
if self.refresh and self.refresh.get('expires_at', 0) < time.time() + 30:
|
|
@@ -299,14 +339,20 @@ def cmd_tests(args):
|
|
|
299
339
|
c = data['counts']
|
|
300
340
|
print(f"build {data['build_id']} [{data['build_status']}] — "
|
|
301
341
|
f"{c['error']} errors, {c['fail']} failures, {c['warning']} warnings")
|
|
342
|
+
loader_rows = 0
|
|
302
343
|
for r in data['results']:
|
|
303
344
|
if r['error_type'] not in ('ERROR', 'FAIL') and not args.warnings:
|
|
304
345
|
continue
|
|
305
|
-
|
|
306
|
-
|
|
346
|
+
is_test = bool(r.get('test')) and r['test'] != 'Unknown'
|
|
347
|
+
if not is_test and not args.details:
|
|
348
|
+
loader_rows += 1 # module-loading summaries: only interesting with --details
|
|
349
|
+
continue
|
|
350
|
+
where = f"{r['module']}.{r['test']}" if is_test else '(module loading)'
|
|
307
351
|
print(f"\n[{r['error_type']}] {where}")
|
|
308
352
|
if args.details:
|
|
309
353
|
print(r['details'])
|
|
354
|
+
if loader_rows:
|
|
355
|
+
print(f"\n(+{loader_rows} module-loading message(s); use --details to see them)")
|
|
310
356
|
|
|
311
357
|
|
|
312
358
|
def cmd_rebuild(args):
|
|
@@ -360,8 +406,15 @@ def cmd_logs(args):
|
|
|
360
406
|
print(ln)
|
|
361
407
|
if not args.follow:
|
|
362
408
|
if args.filter:
|
|
363
|
-
|
|
364
|
-
|
|
409
|
+
searched = data.get('searched_lines')
|
|
410
|
+
total = data.get('total_lines')
|
|
411
|
+
if searched and total and total > searched:
|
|
412
|
+
scope = f"the last {searched} of {total} lines"
|
|
413
|
+
elif searched or total:
|
|
414
|
+
scope = f"all {total or searched} lines"
|
|
415
|
+
else:
|
|
416
|
+
scope = 'the recent log'
|
|
417
|
+
print(f"... ({len(shown)} matching line(s) in {scope})", file=sys.stderr)
|
|
365
418
|
elif data.get('truncated'):
|
|
366
419
|
print(f"... (showing last {len(shown)} lines; use --lines to see more)", file=sys.stderr)
|
|
367
420
|
return
|
|
@@ -560,7 +613,7 @@ def main(argv=None):
|
|
|
560
613
|
help='required when targeting production')
|
|
561
614
|
sp.set_defaults(func=cmd_restart)
|
|
562
615
|
|
|
563
|
-
sp = sub.add_parser('logs', help='tail build logs (build, test or odoo source)')
|
|
616
|
+
sp = sub.add_parser('logs', help='tail build logs (build, test, or odoo source)')
|
|
564
617
|
sp.add_argument('build_id', type=int)
|
|
565
618
|
sp.add_argument('--source', choices=['build', 'test', 'odoo'], default='build')
|
|
566
619
|
sp.add_argument('--lines', type=int, default=200, help='tail length (max 2000)')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|