Coverage for gws-app/gws/lib/osx/__init__.py: 77%

208 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Operating system and shell utilities. 

2 

3This package wraps common operating system tasks used throughout GWS: 

4 

5- running external commands (``run``, ``run_nowait``), 

6- file system operations (``unlink``, ``rename``, ``copy``, ``rmdir``, ``touch``, ``chown``), 

7- file information (``file_mtime``, ``file_age``, ``file_size``, ``file_checksum``), 

8- searching directories (``find_files``, ``find_directories``), 

9- path manipulation (``parse_path``, ``abs_path``, ``rel_path``, ``abs_web_path``), 

10- processes and users (``kill_pid``, ``running_pids``, ``process_rss_size``, ``user_info``). 

11 

12Most file functions accept paths as ``str`` or ``bytes``. Functions that query 

13files return a sentinel value (``-1`` or an empty string) instead of raising 

14when the file cannot be accessed. 

15 

16Example:: 

17 

18 import gws.lib.osx 

19 

20 out = gws.lib.osx.run(['gdalinfo', '--version']) 

21 for path in gws.lib.osx.find_files('/data/projects', ext='json'): 

22 print(path, gws.lib.osx.file_size(path)) 

23""" 

24 

25from typing import Optional 

26 

27import grp 

28import hashlib 

29import os 

30import pwd 

31import re 

32import shlex 

33import shutil 

34import signal 

35import subprocess 

36import time 

37 

38import psutil 

39 

40import gws 

41 

42 

43class Error(gws.Error): 

44 """Generic error raised by OS utilities.""" 

45 

46 pass 

47 

48 

49class TimeoutError(Error): 

50 """Raised when an external command times out.""" 

51 

52 pass 

53 

54 

55_Path = str | bytes 

56 

57 

58def getenv(key: str, default: str = None) -> Optional[str]: 

59 """Return the value of an environment variable. 

60 

61 Args: 

62 key: Variable name. 

63 default: Value to return if the variable is not set. 

64 

65 Returns: 

66 The variable value, or ``default`` if the variable is not set. 

67 """ 

68 return os.getenv(key, default) 

69 

70 

71def run_nowait(cmd: str | list, **kwargs) -> subprocess.Popen: 

72 """Start a process and return immediately, without waiting for it to finish. 

73 

74 By default, the process inherits stdin, stdout and stderr, and the command is not run in a shell. 

75 

76 Args: 

77 cmd: Command to run, as a string or a list of arguments. 

78 kwargs: Arguments to pass to ``subprocess.Popen``. 

79 

80 Returns: 

81 The ``subprocess.Popen`` object of the started process. 

82 """ 

83 

84 args = { 

85 'stdin': None, 

86 'stdout': None, 

87 'stderr': None, 

88 'shell': False, 

89 } 

90 args.update(kwargs) 

91 

92 return subprocess.Popen(cmd, **args) 

93 

94 

95def run(cmd: str | list, input: str = None, echo: bool = False, strict: bool = True, timeout: float = None, **kwargs) -> str: 

96 """Run an external command and wait for it to finish. 

97 

98 A string command is split into arguments with ``shlex.split``. The command is not run in a shell, 

99 and stderr is merged into stdout. 

100 

101 Args: 

102 cmd: Command to run, as a string or a list of arguments. 

103 input: Data to send to the command's stdin. 

104 echo: Let the output go to the console instead of capturing it. 

105 strict: Raise an error on a non-zero exit code. 

106 timeout: Timeout in seconds. 

107 kwargs: Arguments to pass to ``subprocess.Popen``. 

108 

109 Returns: 

110 The captured command output, or an empty string if the output was not captured. 

111 

112 Raises: 

113 ``TimeoutError``: If the command times out. 

114 ``Error``: If the command cannot be run, or exits with a non-zero code and ``strict`` is true. 

115 """ 

116 

117 args = { 

118 'stdin': subprocess.PIPE if input else None, 

119 'stdout': None if echo else subprocess.PIPE, 

120 'stderr': subprocess.STDOUT, 

121 'shell': False, 

122 } 

123 args.update(kwargs) 

124 

125 if isinstance(cmd, str): 

126 cmd = shlex.split(cmd) 

127 

128 gws.log.debug(f'RUN: {cmd=}') 

129 

130 try: 

131 p = subprocess.Popen(cmd, **args) 

132 out, _ = p.communicate(input, timeout) 

133 rc = p.returncode 

134 except subprocess.TimeoutExpired as exc: 

135 raise TimeoutError(f'run: command timed out', repr(cmd)) from exc 

136 except Exception as exc: 

137 raise Error(f'run: failure', repr(cmd)) from exc 

138 

139 if rc: 

140 gws.log.debug(f'RUN_FAILED: {cmd=} {rc=} {out=}') 

141 

142 if rc and strict: 

143 raise Error(f'run: non-zero exit', repr(cmd)) 

144 

145 return _to_str(out or '') 

146 

147 

148def unlink(path: _Path) -> bool: 

149 """Delete a file. 

150 

151 Directories and non-existing paths are ignored. 

152 

153 Args: 

154 path: File path. 

155 

156 Returns: 

157 ``True`` on success or if there was nothing to delete, ``False`` if an OS error occurred. 

158 """ 

159 try: 

160 if os.path.isfile(path): 

161 os.unlink(path) 

162 return True 

163 except OSError as exc: 

164 gws.log.debug(f'OSError: unlink: {exc}') 

165 return False 

166 

167 

168def rename(src: _Path, dst: _Path): 

169 """Move or rename a file or directory. 

170 

171 Args: 

172 src: Source path. 

173 dst: Destination path. 

174 """ 

175 

176 shutil.move(_to_str(src), _to_str(dst)) 

177 

178 

179def chown(path: _Path, user: int = None, group: int = None): 

180 """Change the owner and group of a path. 

181 

182 Args: 

183 path: File path. 

184 user: User ID, defaults to ``gws.c.UID``. 

185 group: Group ID, defaults to ``gws.c.GID``. 

186 """ 

187 os.chown(path, user or gws.c.UID, group or gws.c.GID) 

188 

189 

190def copy(src: _Path, dst: _Path, user: int = None, group: int = None): 

191 """Copy a file and set the owner of the copy. 

192 

193 Args: 

194 src: Source path. 

195 dst: Destination path. 

196 user: User ID of the copy, defaults to ``gws.c.UID``. 

197 group: Group ID of the copy, defaults to ``gws.c.GID``. 

198 """ 

199 shutil.copyfile(src, dst) 

200 os.chown(dst, user or gws.c.UID, group or gws.c.GID) 

201 

202 

203def rmdir(path: _Path) -> bool: 

204 """Remove a directory or a directory tree. 

205 

206 Args: 

207 path: Path to a directory. Can be non-empty. 

208 

209 Returns: 

210 ``True`` if the directory was removed, ``False`` if it does not exist or an OS error occurred. 

211 """ 

212 

213 if not os.path.isdir(path): 

214 return False 

215 try: 

216 shutil.rmtree(path) 

217 return True 

218 except OSError as exc: 

219 gws.log.warning(f'OSError: rmdir: {exc}') 

220 return False 

221 

222 

223def touch(path: _Path): 

224 """Set the access and modification times of a file to the current time. 

225 

226 If the file does not exist, it is created. 

227 

228 Args: 

229 path: File path. 

230 """ 

231 with open(path, 'a'): 

232 os.utime(path, None) 

233 

234 

235def file_mtime(path: _Path) -> float: 

236 """Return the modification time of a path. 

237 

238 Args: 

239 path: File or directory path. 

240 

241 Returns: 

242 Modification time in seconds since the epoch, or ``-1`` if the path cannot be accessed. 

243 """ 

244 try: 

245 return os.stat(path).st_mtime 

246 except OSError: 

247 return -1 

248 

249 

250def file_age(path: _Path) -> int: 

251 """Return the number of seconds since a path was last modified. 

252 

253 Args: 

254 path: File path. 

255 

256 Returns: 

257 Age in seconds, or ``-1`` if the path cannot be accessed. 

258 """ 

259 try: 

260 return int(time.time() - os.stat(path).st_mtime) 

261 except OSError: 

262 return -1 

263 

264 

265def file_size(path: _Path) -> int: 

266 """Return the size of a file. 

267 

268 Args: 

269 path: File path. 

270 

271 Returns: 

272 Size in bytes, or ``-1`` if the path cannot be accessed. 

273 """ 

274 try: 

275 return os.stat(path).st_size 

276 except OSError: 

277 return -1 

278 

279 

280def file_checksum(path: _Path) -> str: 

281 """Return the SHA-256 checksum of a file. 

282 

283 Args: 

284 path: File path. 

285 

286 Returns: 

287 Hex digest of the file content, or an empty string if the file cannot be read. 

288 """ 

289 try: 

290 with open(path, 'rb') as fp: 

291 return hashlib.sha256(fp.read()).hexdigest() 

292 except OSError as exc: 

293 return '' 

294 

295 

296def kill_pid(pid: int, sig_name='TERM') -> bool: 

297 """Send a signal to a process. 

298 

299 Args: 

300 pid: Process ID. 

301 sig_name: Signal name, with or without the ``SIG`` prefix, e.g. ``TERM`` or ``SIGKILL``. 

302 

303 Returns: 

304 ``True`` if the signal was sent or the process does not exist, ``False`` if the signal could not be sent. 

305 """ 

306 sig = getattr(signal, sig_name, None) or getattr(signal, 'SIG' + sig_name) 

307 try: 

308 psutil.Process(pid).send_signal(sig) 

309 return True 

310 except psutil.NoSuchProcess: 

311 return True 

312 except psutil.Error as e: 

313 gws.log.warning(f'send_signal failed, pid={pid!r}, {e}') 

314 return False 

315 

316 

317def running_pids() -> dict[int, str]: 

318 """Return all running processes. 

319 

320 Returns: 

321 A dict mapping process IDs to process names. 

322 """ 

323 d = {} 

324 for p in psutil.process_iter(): 

325 d[p.pid] = p.name() 

326 return d 

327 

328 

329def process_rss_size(unit: str = 'm') -> float: 

330 """Return the Resident Set Size of the current process. 

331 

332 Args: 

333 unit: ``k`` (kilobytes), ``m`` (megabytes) or ``g`` (gigabytes). Any other value returns bytes. 

334 

335 Returns: 

336 The Resident Set Size in the given unit. 

337 """ 

338 n = psutil.Process().memory_info().rss 

339 if unit == 'k': 

340 return n / 1e3 

341 if unit == 'm': 

342 return n / 1e6 

343 if unit == 'g': 

344 return n / 1e9 

345 return n 

346 

347 

348def user_info(uid=None, gid=None) -> dict: 

349 """Return user and group information. 

350 

351 Args: 

352 uid: User ID. Defaults to the user ID of the current process. 

353 gid: Group ID. Defaults to the user's primary group ID. 

354 

355 Returns: 

356 A dict with the keys ``pw_name``, ``pw_uid``, ``pw_gid``, ``pw_dir``, ``pw_shell`` 

357 (from ``struct_passwd``) and ``gr_name``, ``gr_gid`` (from ``struct_group``). 

358 """ 

359 

360 uid = uid or os.getuid() 

361 u = pwd.getpwuid(uid) 

362 

363 r = dict( 

364 pw_name=u.pw_name, 

365 pw_uid=u.pw_uid, 

366 pw_gid=u.pw_gid, 

367 pw_dir=u.pw_dir, 

368 pw_shell=u.pw_shell, 

369 ) 

370 

371 gid = gid or r['pw_gid'] 

372 g = grp.getgrgid(gid) 

373 

374 r['gr_name'] = g.gr_name 

375 r['gr_gid'] = g.gr_gid 

376 

377 return r 

378 

379 

380def find_entries(dirname: _Path, deep: bool = True): 

381 """Find entries in a directory, skipping hidden ones. 

382 

383 Args: 

384 dirname: Path to a directory. 

385 deep: If true, also search subdirectories recursively. 

386 

387 Yields: 

388 ``os.DirEntry`` objects for files and directories whose names do not start with a dot. 

389 """ 

390 de: os.DirEntry 

391 for de in os.scandir(dirname): 

392 if de.name.startswith('.'): 

393 continue 

394 yield de 

395 if de.is_dir() and deep: 

396 yield from find_entries(de.path, deep=deep) 

397 

398 

399def find_files(dirname: _Path, pattern=None, ext=None, deep: bool = True): 

400 """Find files in a directory, skipping hidden ones. 

401 

402 Args: 

403 dirname: Path to a directory. 

404 pattern: Regular expression to search for in the file path. 

405 ext: Extension or a list of extensions to match. Used only if ``pattern`` is not given. 

406 deep: If true, also search subdirectories recursively. 

407 

408 Yields: 

409 Paths of matching files. 

410 """ 

411 if not pattern and ext: 

412 if isinstance(ext, (list, tuple)): 

413 ext = '|'.join(ext) 

414 pattern = '\\.(' + ext + ')$' 

415 

416 for de in find_entries(dirname, deep=deep): 

417 if de.is_file() and (pattern is None or re.search(pattern, de.path)): 

418 yield de.path 

419 

420 

421def find_directories(dirname: _Path, pattern=None, deep: bool = True): 

422 """Find directories in a directory, skipping hidden ones. 

423 

424 Args: 

425 dirname: Path to a directory. 

426 pattern: Regular expression to search for in the directory path. 

427 deep: If true, also search subdirectories recursively. 

428 

429 Yields: 

430 Paths of matching directories. 

431 """ 

432 

433 for de in find_entries(dirname, deep=deep): 

434 if de.is_dir() and (pattern is None or re.search(pattern, de.path)): 

435 yield de.path 

436 

437 

438class ParsePathResult(gws.Data): 

439 """Components of a file path, as returned by ``parse_path``.""" 

440 

441 path: str 

442 """The full path.""" 

443 dirname: str 

444 """The directory part.""" 

445 filename: str 

446 """The file name, including the extension.""" 

447 stem: str 

448 """The file name up to the first dot.""" 

449 extension: str 

450 """The file name after the first dot, without the dot.""" 

451 

452 

453def parse_path(path: _Path) -> ParsePathResult: 

454 """Split a file path into its components. 

455 

456 The extension is everything after the first dot in the file name, so ``a.tar.gz`` 

457 has the stem ``a`` and the extension ``tar.gz``. A file name starting with a dot has no extension. 

458 

459 Args: 

460 path: File path. 

461 

462 Returns: 

463 The path components. 

464 """ 

465 

466 str_path = _to_str(path) 

467 sp = os.path.split(str_path) 

468 

469 pp = ParsePathResult( 

470 path=str_path, 

471 dirname='', 

472 filename='', 

473 stem='', 

474 extension='', 

475 ) 

476 pp.dirname = sp[0] 

477 pp.filename = sp[1] 

478 

479 if pp.filename.startswith('.'): 

480 pp.stem = pp.filename 

481 else: 

482 pp.stem, _, pp.extension = pp.filename.partition('.') 

483 

484 return pp 

485 

486 

487def file_name(path: _Path) -> str: 

488 """Return the file name part of a path. 

489 

490 Args: 

491 path: File path. 

492 

493 Returns: 

494 The last component of the path. 

495 """ 

496 

497 sp = os.path.split(_to_str(path)) 

498 return sp[1] 

499 

500 

501def is_abs_path(path: _Path) -> bool: 

502 """Check if a path is absolute. 

503 

504 Args: 

505 path: File path. 

506 

507 Returns: 

508 ``True`` if the path is absolute. 

509 """ 

510 return os.path.isabs(path) 

511 

512 

513def abs_path(path: _Path, base: _Path) -> str: 

514 """Make a relative path absolute with respect to a base directory or file path. 

515 

516 If ``base`` is a file, its directory is used. An absolute ``path`` is only normalized. 

517 

518 Args: 

519 path: A path. 

520 base: Base directory or file path. 

521 

522 Returns: 

523 The absolute path. 

524 

525 Raises: 

526 ``ValueError``: If ``path`` is relative and ``base`` is empty. 

527 """ 

528 

529 str_path = _to_str(path) 

530 

531 if os.path.isabs(str_path): 

532 return os.path.normpath(str_path) 

533 

534 if not base: 

535 raise ValueError('cannot compute abspath without a base') 

536 

537 if os.path.isfile(base): 

538 base = os.path.dirname(base) 

539 

540 return os.path.abspath(os.path.join(_to_str(base), str_path)) 

541 

542 

543def abs_web_path(path: str, basedir: str) -> Optional[str]: 

544 """Resolve a web path in a base directory. 

545 

546 The path components must consist of letters, digits, ``_`` and ``-``, 

547 the file name can also contain lowercase extensions. This prevents path traversal. 

548 

549 Args: 

550 path: Slash-separated path, as received from a web request. 

551 basedir: Path to the base directory. 

552 

553 Returns: 

554 The path of an existing file in the base directory, or ``None`` if the path is invalid or the file does not exist. 

555 """ 

556 

557 _dir_re = r'^[A-Za-z0-9_-]+$' 

558 _fil_re = r'^[A-Za-z0-9_-]+(\.[a-z0-9]+)*$' 

559 

560 gws.log.debug(f'abs_web_path: trying {path!r} in {basedir!r}') 

561 

562 dirs = [] 

563 for s in path.split('/'): 

564 s = s.strip() 

565 if s: 

566 dirs.append(s) 

567 

568 if not dirs: 

569 gws.log.warning(f'abs_web_path: empty path={path!r}') 

570 return 

571 

572 fname = dirs.pop() 

573 

574 if not all(re.match(_dir_re, p) for p in dirs): 

575 gws.log.warning(f'abs_web_path: invalid dirname in path={path!r}') 

576 return 

577 

578 if not re.match(_fil_re, fname): 

579 gws.log.warning(f'abs_web_path: invalid filename in path={path!r}') 

580 return 

581 

582 p = basedir 

583 if dirs: 

584 p += '/' + '/'.join(dirs) 

585 p += '/' + fname 

586 

587 if not os.path.isfile(p): 

588 gws.log.warning(f'abs_web_path: not a file path={path!r}') 

589 return 

590 

591 return p 

592 

593 

594def rel_path(path: _Path, base: _Path) -> str: 

595 """Make a path relative to a base directory or file path. 

596 

597 If ``base`` is a file, its directory is used. 

598 

599 Args: 

600 path: Path to make relative. 

601 base: Base directory or file path. 

602 

603 Returns: 

604 The relative path. 

605 """ 

606 

607 if os.path.isfile(base): 

608 base = os.path.dirname(base) 

609 

610 return os.path.relpath(_to_str(path), _to_str(base)) 

611 

612 

613def _to_str(p: _Path) -> str: 

614 return p if isinstance(p, str) else bytes(p).decode('utf8') 

615 

616 

617def _to_bytes(p: _Path) -> bytes: 

618 return p if isinstance(p, bytes) else str(p).encode('utf8')