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

210 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +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``, ``mkdir``, ``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 gws.lib.osx.mkdir('/tmp/gws/data') 

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

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

24""" 

25 

26from typing import Optional 

27 

28import grp 

29import hashlib 

30import os 

31import pwd 

32import re 

33import shlex 

34import shutil 

35import signal 

36import subprocess 

37import time 

38 

39import psutil 

40 

41import gws 

42 

43 

44class Error(gws.Error): 

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

46 

47 pass 

48 

49 

50class TimeoutError(Error): 

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

52 

53 pass 

54 

55 

56_Path = str | bytes 

57 

58 

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

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

61 

62 Args: 

63 key: Variable name. 

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

65 

66 Returns: 

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

68 """ 

69 return os.getenv(key, default) 

70 

71 

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

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

74 

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

76 

77 Args: 

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

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

80 

81 Returns: 

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

83 """ 

84 

85 args = { 

86 'stdin': None, 

87 'stdout': None, 

88 'stderr': None, 

89 'shell': False, 

90 } 

91 args.update(kwargs) 

92 

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

94 

95 

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

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

98 

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

100 and stderr is merged into stdout. 

101 

102 Args: 

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

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

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

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

107 timeout: Timeout in seconds. 

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

109 

110 Returns: 

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

112 

113 Raises: 

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

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

116 """ 

117 

118 args = { 

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

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

121 'stderr': subprocess.STDOUT, 

122 'shell': False, 

123 } 

124 args.update(kwargs) 

125 

126 if isinstance(cmd, str): 

127 cmd = shlex.split(cmd) 

128 

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

130 

131 try: 

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

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

134 rc = p.returncode 

135 except subprocess.TimeoutExpired as exc: 

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

137 except Exception as exc: 

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

139 

140 if rc: 

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

142 

143 if rc and strict: 

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

145 

146 return _to_str(out or '') 

147 

148 

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

150 """Delete a file. 

151 

152 Directories and non-existing paths are ignored. 

153 

154 Args: 

155 path: File path. 

156 

157 Returns: 

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

159 """ 

160 try: 

161 if os.path.isfile(path): 

162 os.unlink(path) 

163 return True 

164 except OSError as exc: 

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

166 return False 

167 

168 

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

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

171 

172 Args: 

173 src: Source path. 

174 dst: Destination path. 

175 """ 

176 

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

178 

179 

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

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

182 

183 Args: 

184 path: File path. 

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

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

187 """ 

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

189 

190 

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

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

193 

194 Args: 

195 src: Source path. 

196 dst: Destination path. 

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

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

199 """ 

200 shutil.copyfile(src, dst) 

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

202 

203 

204def mkdir(path: _Path, mode: int = 0o755, user: int = None, group: int = None): 

205 """Create a directory, including missing parent directories. 

206 

207 Does nothing if the directory already exists. 

208 

209 Args: 

210 path: Path to a directory. 

211 mode: Directory creation mode. 

212 user: Directory user. Currently not used. 

213 group: Directory group. Currently not used. 

214 """ 

215 

216 os.makedirs(path, mode, exist_ok=True) 

217 

218 

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

220 """Remove a directory or a directory tree. 

221 

222 Args: 

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

224 

225 Returns: 

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

227 """ 

228 

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

230 return False 

231 try: 

232 shutil.rmtree(path) 

233 return True 

234 except OSError as exc: 

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

236 return False 

237 

238 

239def touch(path: _Path): 

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

241 

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

243 

244 Args: 

245 path: File path. 

246 """ 

247 with open(path, 'a'): 

248 os.utime(path, None) 

249 

250 

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

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

253 

254 Args: 

255 path: File or directory path. 

256 

257 Returns: 

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

259 """ 

260 try: 

261 return os.stat(path).st_mtime 

262 except OSError: 

263 return -1 

264 

265 

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

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

268 

269 Args: 

270 path: File path. 

271 

272 Returns: 

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

274 """ 

275 try: 

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

277 except OSError: 

278 return -1 

279 

280 

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

282 """Return the size of a file. 

283 

284 Args: 

285 path: File path. 

286 

287 Returns: 

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

289 """ 

290 try: 

291 return os.stat(path).st_size 

292 except OSError: 

293 return -1 

294 

295 

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

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

298 

299 Args: 

300 path: File path. 

301 

302 Returns: 

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

304 """ 

305 try: 

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

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

308 except OSError as exc: 

309 return '' 

310 

311 

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

313 """Send a signal to a process. 

314 

315 Args: 

316 pid: Process ID. 

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

318 

319 Returns: 

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

321 """ 

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

323 try: 

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

325 return True 

326 except psutil.NoSuchProcess: 

327 return True 

328 except psutil.Error as e: 

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

330 return False 

331 

332 

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

334 """Return all running processes. 

335 

336 Returns: 

337 A dict mapping process IDs to process names. 

338 """ 

339 d = {} 

340 for p in psutil.process_iter(): 

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

342 return d 

343 

344 

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

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

347 

348 Args: 

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

350 

351 Returns: 

352 The Resident Set Size in the given unit. 

353 """ 

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

355 if unit == 'k': 

356 return n / 1e3 

357 if unit == 'm': 

358 return n / 1e6 

359 if unit == 'g': 

360 return n / 1e9 

361 return n 

362 

363 

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

365 """Return user and group information. 

366 

367 Args: 

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

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

370 

371 Returns: 

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

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

374 """ 

375 

376 uid = uid or os.getuid() 

377 u = pwd.getpwuid(uid) 

378 

379 r = dict( 

380 pw_name=u.pw_name, 

381 pw_uid=u.pw_uid, 

382 pw_gid=u.pw_gid, 

383 pw_dir=u.pw_dir, 

384 pw_shell=u.pw_shell, 

385 ) 

386 

387 gid = gid or r['pw_gid'] 

388 g = grp.getgrgid(gid) 

389 

390 r['gr_name'] = g.gr_name 

391 r['gr_gid'] = g.gr_gid 

392 

393 return r 

394 

395 

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

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

398 

399 Args: 

400 dirname: Path to a directory. 

401 deep: If true, also search subdirectories recursively. 

402 

403 Yields: 

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

405 """ 

406 de: os.DirEntry 

407 for de in os.scandir(dirname): 

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

409 continue 

410 yield de 

411 if de.is_dir() and deep: 

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

413 

414 

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

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

417 

418 Args: 

419 dirname: Path to a directory. 

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

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

422 deep: If true, also search subdirectories recursively. 

423 

424 Yields: 

425 Paths of matching files. 

426 """ 

427 if not pattern and ext: 

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

429 ext = '|'.join(ext) 

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

431 

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

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

434 yield de.path 

435 

436 

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

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

439 

440 Args: 

441 dirname: Path to a directory. 

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

443 deep: If true, also search subdirectories recursively. 

444 

445 Yields: 

446 Paths of matching directories. 

447 """ 

448 

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

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

451 yield de.path 

452 

453 

454class ParsePathResult(gws.Data): 

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

456 

457 path: str 

458 """The full path.""" 

459 dirname: str 

460 """The directory part.""" 

461 filename: str 

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

463 stem: str 

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

465 extension: str 

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

467 

468 

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

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

471 

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

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

474 

475 Args: 

476 path: File path. 

477 

478 Returns: 

479 The path components. 

480 """ 

481 

482 str_path = _to_str(path) 

483 sp = os.path.split(str_path) 

484 

485 pp = ParsePathResult( 

486 path=str_path, 

487 dirname='', 

488 filename='', 

489 stem='', 

490 extension='', 

491 ) 

492 pp.dirname = sp[0] 

493 pp.filename = sp[1] 

494 

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

496 pp.stem = pp.filename 

497 else: 

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

499 

500 return pp 

501 

502 

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

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

505 

506 Args: 

507 path: File path. 

508 

509 Returns: 

510 The last component of the path. 

511 """ 

512 

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

514 return sp[1] 

515 

516 

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

518 """Check if a path is absolute. 

519 

520 Args: 

521 path: File path. 

522 

523 Returns: 

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

525 """ 

526 return os.path.isabs(path) 

527 

528 

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

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

531 

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

533 

534 Args: 

535 path: A path. 

536 base: Base directory or file path. 

537 

538 Returns: 

539 The absolute path. 

540 

541 Raises: 

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

543 """ 

544 

545 str_path = _to_str(path) 

546 

547 if os.path.isabs(str_path): 

548 return os.path.normpath(str_path) 

549 

550 if not base: 

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

552 

553 if os.path.isfile(base): 

554 base = os.path.dirname(base) 

555 

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

557 

558 

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

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

561 

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

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

564 

565 Args: 

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

567 basedir: Path to the base directory. 

568 

569 Returns: 

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

571 """ 

572 

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

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

575 

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

577 

578 dirs = [] 

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

580 s = s.strip() 

581 if s: 

582 dirs.append(s) 

583 

584 if not dirs: 

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

586 return 

587 

588 fname = dirs.pop() 

589 

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

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

592 return 

593 

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

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

596 return 

597 

598 p = basedir 

599 if dirs: 

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

601 p += '/' + fname 

602 

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

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

605 return 

606 

607 return p 

608 

609 

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

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

612 

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

614 

615 Args: 

616 path: Path to make relative. 

617 base: Base directory or file path. 

618 

619 Returns: 

620 The relative path. 

621 """ 

622 

623 if os.path.isfile(base): 

624 base = os.path.dirname(base) 

625 

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

627 

628 

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

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

631 

632 

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

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