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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doploy-cli
3
- Version: 0.2.2
3
+ Version: 0.2.5
4
4
  Summary: doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://doploy.io
@@ -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.write_text(json.dumps(cfg, indent=2) + '\n')
44
- CONFIG_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600: token inside
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
- r = _oauth_token_request(self.base, {
60
- 'grant_type': 'refresh_token',
61
- 'refresh_token': self.refresh['refresh_token'],
62
- 'client_id': self.refresh['client_id'],
63
- })
64
- if r is None:
65
- return False
66
- self.token = r['access_token']
67
- self.refresh['refresh_token'] = r['refresh_token']
68
- self.refresh['expires_at'] = time.time() + int(r.get('expires_in') or 3600)
69
- cfg = _load_config()
70
- cfg.update(access_token=self.token, refresh_token=self.refresh['refresh_token'],
71
- expires_at=self.refresh['expires_at'])
72
- _save_config(cfg)
73
- return True
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
- where = (f"{r['module']}.{r['test']}" if r.get('test') and r['test'] != 'Unknown'
306
- else '(module loading)')
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
- print(f"... ({len(shown)} matching line(s) in the last {data.get('total_lines', '?')}; "
364
- f"raise --lines to search further back)", file=sys.stderr)
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)')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doploy-cli
3
- Version: 0.2.2
3
+ Version: 0.2.5
4
4
  Summary: doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://doploy.io
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "doploy-cli"
7
- version = "0.2.2"
7
+ version = "0.2.5"
8
8
  description = "doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
File without changes
File without changes
File without changes