Coverage for src/edwh/tasks.py: 18%

1132 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-13 17:03 +0200

1import contextlib 

2import datetime as dt 

3import fnmatch 

4import hashlib 

5import io 

6import json 

7import os 

8import pathlib 

9import re 

10import shlex 

11import shutil 

12import subprocess 

13import sys 

14import threading 

15import time 

16import tomllib 

17import traceback 

18import typing as t 

19import warnings 

20from collections import defaultdict 

21from concurrent import futures 

22from dataclasses import dataclass 

23from getpass import getpass 

24from pathlib import Path 

25from typing import Optional 

26 

27import ewok 

28import invoke 

29import keyring 

30import keyring.errors 

31import tabulate 

32import tomlkit # has more features than tomllib 

33import yaml 

34from dotenv import dotenv_values 

35from ewok import Context, Task, format_frame, task 

36from invoke import Promise, Runner 

37from packaging.version import parse as parse_version 

38from rapidfuzz import fuzz 

39from ssh_agent_keyring.backend import SSHAgentKeyring 

40from termcolor import colored, cprint 

41from termcolor._types import Color 

42from typing_extensions import Never 

43 

44from .__about__ import __version__ as edwh_version 

45from .constants import ( 

46 DEFAULT_DOTENV_PATH, 

47 DEFAULT_TOML_NAME, 

48 DOCKER_COMPOSE, 

49 FALLBACK_TOML_NAME, 

50 FILE_START, 

51 LEGACY_TOML_NAME, 

52) 

53from .discover import discover, get_hosts_for_service # noqa F401 - import for export (Remco afblijven) 

54from .health import ( 

55 docker_inspect, 

56 find_container_ids, 

57 find_containers_ids, 

58 get_healths, 

59) 

60 

61# noinspection PyUnresolvedReferences 

62# ^ keep imports for backwards compatibility (e.g. `from edwh.tasks import executes_correctly`) 

63from .helpers import ( # noqa F401 - import for export 

64 AnyDict, 

65 ColorFn, 

66 LineBufferHandler, 

67 NoopHandler, 

68 confirm, 

69 dc_config, 

70 dump_set_as_list, 

71 executes_correctly, 

72 execution_fails, 

73 fabric_read, 

74 fabric_write, 

75 flatten, 

76 interactive_selected_checkbox_values, 

77 interactive_selected_radio_value, 

78 noop, 

79 parse_regex, 

80 print_aligned, 

81 rainbow, 

82 run_pty, 

83 run_pty_ok, 

84 shorten, 

85) 

86from .helpers import generate_password as _generate_password 

87 

88# noinspection PyUnresolvedReferences 

89# ^ keep imports for other tasks to register them! 

90from .meta import is_installed, plugins, self_update # noqa 

91 

92 

93def copy_fallback_toml( 

94 tomlfile: str | Path = DEFAULT_TOML_NAME, 

95 fallbacks: t.Collection[str | Path] = (LEGACY_TOML_NAME, FALLBACK_TOML_NAME), 

96 force: bool = False, 

97) -> bool: 

98 tomlfile_path = Path(tomlfile) 

99 

100 if tomlfile_path.exists() and not force: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 return False 

102 

103 for fallback_name in fallbacks: 103 ↛ 112line 103 didn't jump to line 112 because the loop on line 103 didn't complete

104 fallback_path = Path(fallback_name) 

105 

106 if fallback_name is None or not fallback_path.exists(): 

107 continue 

108 

109 shutil.copy(fallback_path, tomlfile_path) 

110 return True 

111 

112 tomlfile_path.touch() 

113 return False 

114 

115 

116def service_names( 

117 service_arg: t.Collection[str] | None, 

118 default: t.Literal["all", "minimal", "logs", "celeries"] | None = None, 

119) -> list[str]: 

120 """ 

121 Returns a list of matching servicenames based on ALL_SERVICES. filename globbing is applied. 

122 

123 Use service_names(['*celery*','pg*']) to select all celery services, and all of pg related instances. 

124 :param service_arg: list of services or service selectors using wildcards 

125 :param default: which services to return if service_arg is empty? 

126 :return: list of unique services names that match the given list 

127 """ 

128 

129 config = TomlConfig.load() 

130 if not config: 

131 return [] 

132 

133 selected = set() 

134 if service_arg is None: 

135 service_arg = [] 

136 elif isinstance(service_arg, str): 

137 service_arg = service_arg.split(",") 

138 else: 

139 service_arg = list(flatten([_.split(",") for _ in service_arg])) 

140 

141 service_arg = [_.strip("/") for _ in service_arg] if service_arg else ([str(default)] if default else []) 

142 

143 # NOT elif because you can pass -s "minimal" -s "celeries" for example 

144 if "all" in service_arg: 

145 service_arg.remove("all") 

146 service_arg.extend(config.all_services) 

147 if "minimal" in service_arg: 

148 service_arg.remove("minimal") 

149 service_arg.extend(config.services_minimal) 

150 if "logs" in service_arg: 

151 service_arg.remove("logs") 

152 service_arg.extend(config.services_log) 

153 if "celeries" in service_arg: 

154 service_arg.remove("celeries") 

155 service_arg.extend(config.celeries) 

156 if "pgq" in service_arg: 

157 service_arg.remove("pgq") 

158 service_arg.extend(config.pgq) 

159 if "db" in service_arg and config.services_db: 

160 service_arg.remove("db") 

161 service_arg.extend(config.services_db) 

162 

163 # service_arg is specified, filter through all available services: 

164 

165 for service in service_arg: 

166 selected.update(fnmatch.filter(config.all_services, service)) 

167 

168 if service_arg and not selected: 

169 # when no service matches the name, don't return an empty list, as that would `up` all services 

170 # instead of the wanted list. This includes typos, where a single typo could cause all services to be started. 

171 cprint(f"ERROR: No services found matching: {service_arg!r}", color="red") 

172 exit(1) 

173 return list(selected) 

174 

175 

176def calculate_schema_hash(quiet: bool = False) -> str: 

177 """ 

178 Calculates the sha1 digest of the files in the shared_code folder. 

179 

180 When anything is changed, it will have a different hash, so migrate will be triggered. 

181 """ 

182 filenames = sorted(Path("./shared_code").glob("**/*")) 

183 # ignore those pesky __pycache__ folders 

184 filenames = [_ for _ in filenames if "__pycache__" not in str(_) and _.is_file()] 

185 hasher = hashlib.sha256(b"") 

186 for filename in filenames: 

187 hasher.update(filename.read_bytes()) 

188 

189 if not quiet: 

190 print("schema hash: ", hasher.hexdigest()) 

191 return hasher.hexdigest() 

192 

193 

194def task_for_namespace(ctx: Context, namespace: str, task_name: str) -> Task | None: 

195 """ 

196 Get a task by namespace + task_name. 

197 

198 Example: 

199 namespace: local, task_name: setup 

200 """ 

201 

202 if ns := ewok.find_namespace(ctx, namespace): 

203 return t.cast(Task, ns.tasks.get(task_name)) 

204 

205 return None 

206 

207 

208def task_for_identifier(ctx: Context, identifier: str) -> Task | None: 

209 collection = ewok.tasks(ctx) 

210 

211 return collection.tasks.get(identifier) 

212 

213 

214def get_task(ctx: Context, identifier: str = "") -> Task | None: 

215 """ 

216 Get a task by the identifier you would use in the terminal. 

217 

218 Example: 

219 local.setup 

220 """ 

221 if not identifier: 

222 stack = traceback.extract_stack(limit=2) 

223 cprint( 

224 "WARN: get_task(identifier) is deprecated in favor of get_task(invoke.Context, identifier)", 

225 color="yellow", 

226 ) 

227 format_frame(stack[0]) 

228 return None 

229 

230 if "." in identifier: 

231 return task_for_namespace(ctx, *identifier.split(".")) 

232 else: 

233 return task_for_identifier(ctx, identifier) 

234 

235 

236_dotenv_settings: dict[str, dict[str, str]] = {} 

237 

238 

239def _apply_env_vars_to_template(source_lines: list[str], env: dict[str, str]) -> list[str]: 

240 needle = re.compile(r"# *template:") 

241 

242 new_lines = [] 

243 for line in source_lines: 

244 if not needle.findall(line): 

245 # nothing found, try next line 

246 new_lines.append(line) 

247 continue 

248 

249 # split on template definition: 

250 old, template = needle.split(line) 

251 template = template.strip() 

252 # save the indention part, add an addition if no indention was found 

253 indention = (re.findall(r"^\s*", old) + [""])[0] # noqa: RUF005 would make this complex 

254 if not old.lstrip().startswith("#"): 

255 # skip comment only lines 

256 new = template.format(**env) 

257 # reconstruct the line for the yaml file 

258 line = f"{indention}{new} # template: {template}" 

259 new_lines.append(line) 

260 return new_lines 

261 

262 

263# used for treafik config 

264def apply_dotenv_vars_to_yaml_templates(yaml_path: Path, dotenv_path: Path = DEFAULT_DOTENV_PATH) -> None: 

265 """Indention preserving templating of yaml files, uses dotenv_path for variables. 

266 

267 Pythong formatting is used with a dictionary of environment variables used from os environment variables 

268 updated by the dotenv_path parsed .dotenv entries. 

269 Templating is found using `# template:` 

270 indention is saved, everything after the above indicator is python string formatted and written back. 

271 

272 Example: 

273 |config: 

274 | email: some@one.com # template: {EMAIL} 

275 

276 assuming dotenv file contains: 

277 |EMAIL=yep@thatsme.com 

278 

279 applying this function will result in: 

280 |config: 

281 | email: yep@thatsme.com # template: {EMAIL} 

282 """ 

283 env = os.environ.copy() 

284 env |= read_dotenv(dotenv_path) 

285 # env_variable_re = re.compile(r'\$[A-Z0-9]') 

286 with yaml_path.open(mode="r+") as yaml_file: 

287 source_lines = yaml_file.read().split("\n") 

288 new_lines = _apply_env_vars_to_template(source_lines, env) 

289 # move filepointer to the start of the file 

290 yaml_file.seek(0, FILE_START) 

291 # write all lines and newlines to the file 

292 yaml_file.write("\n".join(new_lines)) 

293 # and remove any part that might be left over (when the new file is shorter than the old one) 

294 yaml_file.truncate() 

295 

296 

297# Singleton but depending on 'fname' (toml file name) and 'dotenv_path' 

298tomlconfig_singletons: dict[tuple[str, str], "TomlConfig"] = {} 

299 

300 

301def throw(error: Exception) -> Never: 

302 """ 

303 Functional raise, useful for if ... else ... or callbacks. 

304 """ 

305 raise error 

306 

307 

308class ServicesTomlConfig(t.TypedDict, total=False): 

309 """ 

310 [services] section of .toml 

311 """ 

312 

313 services: t.Literal["discover"] | list[str] 

314 minimal: list[str] 

315 include_celeries_in_minimal: str # 'true'/'1' or 'false'/'0' 

316 include_pgq_in_minimal: str # 'true'/'1' or 'false'/'0' 

317 log: list[str] 

318 db: list[str] 

319 

320 

321# todo: keyof<ServicesTomlConfig> or something? 

322TomlKeys = t.Literal["services", "minimal", "include_celeries_in_minimal", "include_pgq_in_minimal", "log", "db"] 

323 

324 

325class ConfigTomlDict(t.TypedDict, total=True): 

326 """ 

327 Data from .toml 

328 """ 

329 

330 services: ServicesTomlConfig 

331 dotenv: AnyDict 

332 

333 

334def boolish(value: t.Literal["y", "yes", "t", "true", "1", "n", "no", "false", "f", "0"] | str | int) -> bool: 

335 """ 

336 Convert a given value to a boolean. 

337 

338 Args: 

339 value (Union[str, int]): The value to be converted. 

340 Accepts strings representing true/false values such as "y", "yes", "t", "true", "1" for true 

341 and "n", "no", "false", "f", "0" for false, as well as integers. 

342 

343 Returns: 

344 bool: The boolean representation of the input value. 

345 """ 

346 return bool(value) and str(value)[0].strip().lower() in {"y", "t", "1"} 

347 

348 

349@dataclass 

350class TomlConfig: 

351 config: ConfigTomlDict 

352 all_services: list[str] 

353 celeries: list[str] 

354 pgq: list[str] 

355 services_minimal: list[str] 

356 services_log: list[str] 

357 services_db: list[str] 

358 services_health: list[str] 

359 

360 dotenv_path: Path 

361 

362 # __loaded was replaced with tomlconfig_singletons 

363 

364 @classmethod 

365 def load( 

366 cls, 

367 fname: str | Path = DEFAULT_TOML_NAME, 

368 dotenv_path: Optional[Path] = None, 

369 cache: bool = True, 

370 ) -> "TomlConfig | None": 

371 """ 

372 Load config toml file, raising an error if it does not exist. 

373 

374 Since this file should be in .git error suppression is not needed. 

375 Returns a dictionary with CONFIG, ALL_SERVICES, CELERIES and MINIMAL_SERVICES 

376 """ 

377 singleton_key = (str(fname), str(dotenv_path)) 

378 ctx = t.cast(Context, invoke.Context()) 

379 

380 if cache and (instance := tomlconfig_singletons.get(singleton_key)): 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 return instance 

382 

383 config_path = Path(fname) # probably config.toml 

384 dc_path = Path("docker-compose.yml") 

385 

386 if not dc_path.exists(): 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true

387 cprint( 

388 "docker-compose.yml file is missing, toml config could not be loaded. Functionality may be limited.", 

389 color="yellow", 

390 ) 

391 return None 

392 

393 if not config_path.exists(): 393 ↛ 396line 393 didn't jump to line 396 because the condition on line 393 was always true

394 setup(ctx) 

395 

396 config = read_toml_config(config_path) 

397 # todo: if setup runs, reload config 

398 

399 if "services" not in config: 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true

400 setup(ctx) 

401 config = read_toml_config(config_path) 

402 

403 toml_keys = t.get_args(TomlKeys) 

404 for toml_key in toml_keys: 

405 if toml_key not in config["services"]: 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true

406 setup(ctx) 

407 config = read_toml_config(config_path) 

408 

409 if config["services"].get("services", "discover") == "discover": 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true

410 compose = load_dockercompose_with_includes(dc_path=dc_path) 

411 

412 all_services = list(compose["services"].keys()) 

413 else: 

414 all_services = t.cast(list[str], config["services"]["services"]) 

415 

416 celeries = [s for s in all_services if "celery" in s.lower()] 

417 pgq = [s for s in all_services if "pgq" in s.lower()] 

418 

419 minimal_services = config["services"]["minimal"] 

420 if boolish(config["services"].get("include_celeries_in_minimal", "false")): 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true

421 minimal_services += celeries 

422 if boolish(config["services"].get("include_pgq_in_minimal", "false")): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 minimal_services += pgq 

424 

425 tomlconfig_singletons[singleton_key] = instance = TomlConfig( 

426 config=config, 

427 all_services=all_services, 

428 celeries=celeries, 

429 pgq=pgq, 

430 services_minimal=minimal_services, 

431 services_log=config["services"]["log"], 

432 services_db=config["services"]["db"], 

433 services_health=config["services"].get("health", []), 

434 dotenv_path=Path(config.get("dotenv", {}).get("path", dotenv_path or DEFAULT_DOTENV_PATH)), 

435 ) 

436 return instance 

437 

438 

439def process_env_file(env_path: Path) -> dict[str, str]: 

440 if not env_path.exists(): 

441 return {} 

442 

443 values = dotenv_values(env_path) 

444 return t.cast(dict[str, str], dict(values)) 

445 

446 

447def exists_nonempty(path: Path) -> bool: 

448 """ 

449 Checks whether a given file path exists and is non-empty. 

450 

451 This function determines if the specified path represents an existing 

452 file and whether it contains any data (i.e., its size is greater than 0). 

453 

454 Args: 

455 path (Path): A Path object representing the file path to check. 

456 

457 Returns: 

458 bool: True if the file exists and is non-empty, otherwise False. 

459 """ 

460 return path.exists() and path.stat().st_size > 0 

461 

462 

463def read_dotenv(env_path: Path = DEFAULT_DOTENV_PATH) -> dict[str, str]: 

464 """ 

465 Read .env file from env_path and return a dict of key/value pairs. 

466 

467 If the .env file doesn't exist at env_path, traverse up the directory tree 

468 looking for one, stopping when a docker-compose.* file is found (project root). 

469 

470 :param env_path: optional path to .env file 

471 :return: dict of key/value pairs from the .env file 

472 """ 

473 if not env_path: 

474 # for backwards compatibility, if None is passed: still use the default. 

475 env_path = DEFAULT_DOTENV_PATH 

476 

477 cache_key = str(env_path) if env_path else "." 

478 if existing := _dotenv_settings.get(cache_key): 

479 # 'cache' 

480 return existing 

481 

482 # First try the exact path provided 

483 if exists_nonempty(env_path): 

484 items = process_env_file(env_path) 

485 _dotenv_settings[cache_key] = items 

486 return items 

487 

488 # If not found and it's the default name, traverse up the tree 

489 if env_path.name == DEFAULT_DOTENV_PATH.name: 

490 current_dir = Path.cwd() 

491 

492 while current_dir != current_dir.parent: # Stop at filesystem root 

493 # Look for .env in current directory 

494 potential_env = current_dir / DEFAULT_DOTENV_PATH.name 

495 if exists_nonempty(potential_env): 

496 items = process_env_file(potential_env) 

497 _dotenv_settings[cache_key] = items 

498 return items 

499 

500 # Check if we've reached a project root (docker-compose file exists) 

501 if any(current_dir.glob("docker-compose.*")) and current_dir != Path.cwd(): 

502 # Found project root, stop searching if we're not in the original directory 

503 break 

504 

505 # Move up one directory 

506 current_dir = current_dir.parent 

507 

508 # If still not found, return empty dict (existing behavior) 

509 items = {} 

510 _dotenv_settings[cache_key] = items 

511 return items 

512 

513 

514# noinspection PyDefaultArgument 

515def warn_once( 

516 warning: str, 

517 previously_shown: list[str] = [], 

518 color: Optional[Color] = None, 

519 **print_kwargs: t.Any, 

520) -> None: 

521 """ 

522 Mutable default 'previously_shown' is there on purpose, to track which warnings were already shown! 

523 """ 

524 if warning in previously_shown: 

525 # already seen 

526 return 

527 

528 previously_shown.append(warning) 

529 

530 cprint( 

531 warning, 

532 color=color, 

533 **print_kwargs, 

534 ) 

535 

536 

537type DefaultFn = t.Callable[[], Optional[str]] 

538 

539 

540def check_env( 

541 key: str, 

542 default: Optional[str] | DefaultFn, 

543 comment: str, 

544 # optionals: 

545 prefix: Optional[str] = None, 

546 suffix: Optional[str] = None, 

547 # note: 'postfix' should be 'suffix' but to be backwards compatible we can't just remove it! 

548 postfix: Optional[str] = None, 

549 # different config paths: 

550 env_path: Optional[str | Path] = None, 

551 force_default: Optional[bool] = False, 

552 allowed_values: t.Iterable[str] = (), 

553 toml_path: None = None, 

554) -> str: 

555 """ 

556 Test if key is in .env file path, appends prompted or default value if missing. 

557 

558 Args: 

559 key: The environment variable key to check. 

560 default: The default value to use if the key is not found. Can also be a function for lazy evaluation. 

561 comment: A comment describing the purpose of the environment variable. 

562 prefix: An optional prefix to prepend to the key. 

563 suffix: An optional suffix to append to the key. 

564 postfix: An optional parameter for backward compatibility with 'suffix'. 

565 env_path: An optional path to the environment file. 

566 force_default: Whether to force the default value even if the key exists. 

567 allowed_values: A list of allowed values for the environment variable. 

568 toml_path: Optional path to a TOML configuration file. 

569 

570 Returns: 

571 The value of the environment variable, either from the file, default, or forced. 

572 

573 

574 Example: 

575 > check_env( 

576 > key="SOME_KEY", 

577 > default=lambda c: slow_function() 

578 > comment="This key has a lazily evaluated default", 

579 > ... 

580 > ) 

581 """ 

582 if toml_path: 

583 warn_once(f"Deprecated: toml_path ({toml_path} is not used by check_env anymore.)", color="yellow") 

584 

585 env_path = Path(env_path or DEFAULT_DOTENV_PATH) 

586 if not env_path.exists(): 

587 env_path.parent.mkdir(parents=True, exist_ok=True) 

588 env_path.touch() 

589 

590 # config = TomlConfig.load(toml_path, env_path) 

591 env = read_dotenv(env_path) 

592 

593 if key in env: 

594 return env[key] 

595 

596 if suffix and postfix: 

597 warnings.warn( 

598 "! both a 'suffix' and a 'postfix' parameter were specified, " 

599 "but only 'suffix' will be used since 'postfix' is just an alias!", 

600 category=DeprecationWarning, 

601 ) 

602 elif postfix: 

603 warnings.warn( 

604 "The 'postfix' option has been replaced by 'suffix' and may be removed in the future.", 

605 category=DeprecationWarning, 

606 ) 

607 

608 suffix = suffix or postfix 

609 

610 if callable(default): 

611 default = default() # type: ignore 

612 

613 non_interactive = os.environ.get("EDWH_NON_INTERACTIVE", "0") == "1" 

614 from_env = os.environ.get("EDWH_FROM_ENV", "0") == "1" 

615 

616 if force_default: 

617 value = default or "" 

618 elif from_env: 

619 value = os.environ.get(key, default or "") 

620 if not value: 

621 raise RuntimeError(f"Environment variable {key} not found and no default provided (--from-env mode)") 

622 elif non_interactive: 

623 raise RuntimeError(f"No default value provided for {key} in --non-interactive mode") 

624 else: 

625 response = input(f"Enter value for {key} ({comment})\n default=`{default}`: ") 

626 value = response.strip() or default or "" 

627 

628 if allowed_values and value not in allowed_values: 

629 raise ValueError(f"Invalid value '{response}'. Please choose one of {allowed_values}") 

630 

631 str_value = str(value) 

632 

633 if prefix: 

634 str_value = prefix + str_value 

635 if suffix: 

636 str_value += suffix 

637 

638 with env_path.open(mode="a") as env_file: 

639 # append mode ensures we're writing at the end 

640 env_file.write(f"\n{key.upper()}={str_value}") 

641 

642 # update in memory too: 

643 env[key] = str_value 

644 

645 return str_value 

646 

647 

648def get_env_value(key: str, default: str | type[Exception] = KeyError) -> str: 

649 """ 

650 Get a specific env value by name. 

651 If no default is given and the key is not found, a KeyError is raised. 

652 """ 

653 env = read_dotenv() 

654 if key in env: 

655 return env[key] 

656 elif isinstance(default, type) and issubclass(default, Exception): 

657 raise default(key) 

658 

659 return default 

660 

661 

662def set_env_value(path: Path, target: str, value: str | None) -> None: 

663 """ 

664 Update/set environment variables in the .env file, keeping comments intact. 

665 

666 set_env_value(Path('.env'), 'SCHEMA_VERSION', schemaversion) 

667 

668 Args: 

669 path: pathlib.Path designating the .env file 

670 target: key to write, probably best to use UPPERCASE 

671 value: string value to write (or anything that converts to a string using str()). 

672 If None, the key will be removed from the file (if present) and not added. 

673 """ 

674 path.touch(exist_ok=True) 

675 

676 with path.open(mode="r") as env_file: 

677 # open the .env file and read every line in the inlines 

678 inlines = env_file.read().split("\n") 

679 

680 outlines = [] # lines for output 

681 geschreven = False 

682 for line in inlines: 

683 if line.strip().startswith("#"): 

684 # ignore comments 

685 outlines.append(line) 

686 continue 

687 # remove redundant whitespace 

688 line = line.strip() 

689 if not line: 

690 # remove empty lines 

691 continue 

692 # convert to tuples 

693 key, _oldvalue = line.split("=", 1) 

694 # clean the key and value 

695 key = key.strip() 

696 if key == target: 

697 if value is None: 

698 # Remove this key by not adding it to outlines 

699 geschreven = True 

700 continue 

701 # add the new tuple to the lines 

702 outlines.append(f"{key}={value}") 

703 geschreven = True 

704 else: 

705 # or leave it as it is 

706 outlines.append(line) 

707 

708 if not geschreven and value is not None: 

709 outlines.append(f"{target.strip().upper()}={value.strip()}") 

710 

711 with path.open(mode="w") as env_file: 

712 env_file.write("\n".join(outlines)) 

713 env_file.write("\n") 

714 

715 

716def write_content_to_toml_file( 

717 content_key: TomlKeys, 

718 content: str | list[str] | None, 

719 filename: str | Path = DEFAULT_TOML_NAME, 

720 allow_empty: bool = False, 

721) -> None: 

722 if not (content or allow_empty): 

723 return 

724 

725 filepath = Path(filename) 

726 

727 config_toml_file = read_toml_config(filepath) 

728 config_toml_file["services"][content_key] = content # type: ignore 

729 

730 write_toml_config(filepath, config_toml_file) 

731 

732 

733def get_content_from_toml_file( 

734 services: list[str], 

735 toml_contents: ConfigTomlDict, 

736 content_key: TomlKeys, 

737 content: str, 

738 default: list[str] | str, 

739 overwrite: bool = False, 

740 allow_empty: bool = False, 

741) -> list[str] | str | None: 

742 """ 

743 Gets content from a TOML file. 

744 feat/ew_setup_2084/gwen 

745 :param services: A list of services. 

746 :param toml_contents: A dictionary representing the TOML file. 

747 :param content_key: The key to look for in the TOML file. 

748 :param content: The content to display to the user. 

749 :param default: The default value to return if the conditions are not met. 

750 :param overwrite: don't skip if key already exists 

751 :param allow_empty: add an option to the dropdown to select no containers (e.g. for a service without database) 

752 

753 :return: The content from the TOML file or the default value. 

754 :rtype: Any 

755 """ 

756 

757 has_existing_value = "services" in toml_contents and content_key in toml_contents["services"] 

758 

759 if has_existing_value and not overwrite: 

760 print("skipping", content_key) 

761 return "" 

762 

763 selected: set[str] = set() 

764 if has_existing_value: 

765 selected.update(toml_contents["services"][content_key]) 

766 elif default: 

767 selected.update(default) 

768 

769 selection = interactive_selected_checkbox_values(services, content, selected=selected, allow_empty=allow_empty) 

770 if allow_empty and selection is None: 

771 return None 

772 

773 return selection or default 

774 

775 

776def setup_config_file(filename: str | Path = DEFAULT_TOML_NAME) -> None: 

777 """ 

778 sets up config.toml for use 

779 """ 

780 filepath = Path(filename) 

781 

782 config_toml_file = tomlkit.loads(filepath.read_text()) 

783 if "services" not in config_toml_file: 

784 filepath.write_text("\n[services]\n") 

785 

786 

787def read_toml_config(fp: Path) -> ConfigTomlDict: 

788 """ 

789 Read the config at filepath, and cast to the right typeddict. 

790 """ 

791 config_toml_file = tomlkit.loads(fp.read_text()) 

792 

793 return t.cast(ConfigTomlDict, config_toml_file) 

794 

795 

796def write_toml_config(fp: Path, config: ConfigTomlDict) -> int: 

797 return fp.write_text(tomlkit.dumps(config)) 

798 

799 

800def include_services( 

801 service: str, services: list[str], key: TomlKeys, config_toml_file: ConfigTomlDict, overwrite: bool 

802): 

803 # adds to services 

804 if not services: 

805 write_content_to_toml_file(key, "false") 

806 elif services and ("services" not in config_toml_file or key not in config_toml_file["services"] or overwrite): 

807 # check if user wants to include service 

808 include_service = ( 

809 "true" if confirm(f"do you want to include {service} in minimal [Yn]: ", default=True) else "false" 

810 ) 

811 write_content_to_toml_file(key, include_service) 

812 

813 

814def write_user_input_to_config_toml( 

815 all_services: list[str], 

816 filename: str | Path = DEFAULT_TOML_NAME, 

817 overwrite: bool = False, 

818) -> TomlConfig | None: 

819 """ 

820 write chosen user dockers to config.toml 

821 

822 :param all_services: list of all docker services that are in the docker-compose.yml 

823 :param filename: which toml file to write to (default = .toml) 

824 :param overwrite: by default, skip keys that already have a value 

825 :return: 

826 """ 

827 filepath = Path(filename) 

828 services_no_workers = [service for service in all_services if "pgq" not in service and "celery" not in service] 

829 services_pgq = [service for service in all_services if "pgq" in service] 

830 services_celery = [service for service in all_services if "celery" in service] 

831 setup_config_file() 

832 

833 # services 

834 services_list = "discover" 

835 write_content_to_toml_file("services", services_list) 

836 

837 config_toml_file = read_toml_config(filepath) 

838 

839 include_services("pgq", services_pgq, "include_pgq_in_minimal", config_toml_file, overwrite) 

840 include_services("celery", services_celery, "include_celeries_in_minimal", config_toml_file, overwrite) 

841 

842 # get chosen services for minimal and logs 

843 minimal_services = ( 

844 services_no_workers 

845 if config_toml_file["services"]["services"] == "discover" 

846 else config_toml_file["services"]["services"] 

847 ) 

848 

849 # services 

850 content = get_content_from_toml_file( 

851 minimal_services, 

852 config_toml_file, 

853 "minimal", 

854 "select minimal services you want to run on `ew up`: ", 

855 [], 

856 overwrite=overwrite, 

857 ) 

858 write_content_to_toml_file("minimal", content, filename) 

859 

860 content = get_content_from_toml_file( 

861 minimal_services, 

862 config_toml_file, 

863 "log", 

864 "select services to be logged: ", 

865 [], 

866 overwrite=overwrite, 

867 ) 

868 write_content_to_toml_file("log", content, filename) 

869 

870 # db 

871 possibly_postgres = [_ for _ in minimal_services if "pg-" in _] 

872 content = get_content_from_toml_file( 

873 minimal_services, 

874 config_toml_file, 

875 "db", 

876 "select database containers: ", 

877 possibly_postgres, 

878 overwrite=overwrite, 

879 allow_empty=True, 

880 ) 

881 write_content_to_toml_file("db", content or [], filename, allow_empty=content is None) 

882 

883 return TomlConfig.load(filename, cache=False) 

884 

885 

886def load_dockercompose_with_includes( 

887 c: Optional[invoke.Context] = None, 

888 dc_path: str | Path = "docker-compose.yml", 

889) -> AnyDict: 

890 """ 

891 Since we're using `docker compose` with includes, simply yaml loading docker-compose.yml is not enough anymore. 

892 

893 This function uses the `docker compose config` command to properly load the entire config with all enabled services. 

894 """ 

895 if not c: 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true

896 c = t.cast(Context, invoke.Context()) 

897 

898 if not isinstance(dc_path, Path): 898 ↛ 901line 898 didn't jump to line 901 because the condition on line 898 was always true

899 dc_path = Path(dc_path) 

900 

901 if not dc_path.exists(): 901 ↛ 902line 901 didn't jump to line 902 because the condition on line 901 was never true

902 raise FileNotFoundError(dc_path) 

903 

904 if ran := c.run(f"{DOCKER_COMPOSE} -f {dc_path} config", hide=True): 904 ↛ anywhereline 904 didn't jump anywhere: it always raised an exception.

905 processed_config = ran.stdout.strip() 

906 # mimic a file to load the yaml from 

907 fake_file = io.StringIO(processed_config) 

908 return t.cast(AnyDict, yaml.safe_load(fake_file)) 

909 else: 

910 return {} 

911 

912 

913def prompt_validate_sudo_pass(c: Context): 

914 sudo_pass = getpass("Please enter the sudo password: ") 

915 c.config.sudo.password = sudo_pass 

916 

917 try: 

918 result = c.sudo("echo ''", warn=True, hide=True) 

919 if not (result and result.ok): 

920 raise invoke.exceptions.AuthFailure(result, "sudo") 

921 

922 cprint("Sudo password accepted!", color="green", file=sys.stderr) 

923 return sudo_pass 

924 except invoke.exceptions.AuthFailure as e: 

925 cprint(str(e), color="red", file=sys.stderr) 

926 return None 

927 

928 

929def ssh_agent_keyring_config_path() -> Path: 

930 config_root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) 

931 return config_root / "ssh-agent-keyring" / "config.json" 

932 

933 

934def use_configured_ssh_agent_keyring() -> bool: 

935 if not ssh_agent_keyring_config_path().exists(): 

936 return False 

937 

938 keyring.set_keyring(SSHAgentKeyring()) 

939 return True 

940 

941 

942def configure_ssh_agent_keyring() -> bool: 

943 result = subprocess.run(["ssh-add", "-L"], text=True, capture_output=True, check=False) 

944 public_keys = [key for key in result.stdout.splitlines() if key.startswith("ssh-")] 

945 if not public_keys: 

946 cprint("No public keys are available from SSH_AUTH_SOCK.", color="red") 

947 return False 

948 

949 options = { 

950 public_key: f"{parts[0]} {parts[1][:20]}{' '.join(parts[2:])}".strip() 

951 for public_key in public_keys 

952 if len(parts := public_key.split()) >= 2 

953 } 

954 public_key = interactive_selected_radio_value(options, prompt="Select the SSH key for EDWH's encrypted keyring:") 

955 if not public_key: 

956 return False 

957 

958 config_path = ssh_agent_keyring_config_path() 

959 config_path.parent.mkdir(parents=True, exist_ok=True) 

960 config = json.loads(config_path.read_text()) if config_path.exists() else {} 

961 config["public_key"] = public_key 

962 config_path.write_text(json.dumps(config, indent=2) + "\n") 

963 

964 keyring.set_keyring(SSHAgentKeyring()) 

965 return True 

966 

967 

968def ensure_keyring_unlocked() -> bool: 

969 """Offer a secure SSH-agent fallback when the system keyring is locked.""" 

970 use_configured_ssh_agent_keyring() 

971 

972 try: 

973 keyring.get_password("edwh", "sudo") 

974 except keyring.errors.KeyringLocked: 

975 if confirm("The system keyring is locked. Would you like to use your SSH agent instead? [Yn]", default=True): 

976 return configure_ssh_agent_keyring() 

977 return False 

978 

979 return True 

980 

981 

982@task() 

983def require_sudo(c: Context) -> bool: 

984 """ 

985 Can be used as a 'pre' hook for invoke tasks to make sure sudo is ready to be used, 

986 without prompting for a password later on (which could fail due to not passing data to stdin on a remote host). 

987 

988 Usage: 

989 @task(pre=[require_sudo]) 

990 def setup(c): ... 

991 

992 # or, if you're not in a @task but you do have access to c (Context), e.g. in a helper function: 

993 def my_func(c): 

994 if require_sudo(c): 

995 c.sudo('echo "I am the captain now."') 

996 

997 """ 

998 use_configured_ssh_agent_keyring() 

999 

1000 ran = c.run("sudo --non-interactive echo ''", warn=True, hide=True) 

1001 if ran and ran.ok: 

1002 # prima 

1003 return True 

1004 

1005 with contextlib.suppress(Exception): 

1006 if current := keyring.get_password("edwh", "sudo"): 

1007 c.config.sudo.password = current 

1008 return True 

1009 

1010 if prompt_validate_sudo_pass(c): 

1011 return True 

1012 else: 

1013 cprint("Stopping now.") 

1014 exit(1) 

1015 

1016 

1017def build_toml(c: Context, overwrite: bool = False) -> TomlConfig | None: 

1018 try: 

1019 docker_compose = load_dockercompose_with_includes(c) 

1020 except FileNotFoundError: 

1021 cprint("docker-compose.yml file is missing, setup could not be completed!", color="red") 

1022 return None 

1023 

1024 services: AnyDict = docker_compose["services"] 

1025 return write_user_input_to_config_toml(list(services.keys()), overwrite=overwrite) 

1026 

1027 

1028@task() 

1029def sudo(c: Context): 

1030 if not ensure_keyring_unlocked(): 

1031 return 

1032 

1033 # 1. 

1034 # check current status in keyring 

1035 try: 

1036 current = keyring.get_password("edwh", "sudo") 

1037 except Exception: 

1038 current = None 

1039 

1040 # 2. change text based on current status (re-authorize) 

1041 if current: 

1042 allow = confirm( 

1043 "Would you like to re-authorize edwh to run sudo commands without password entry? [Yn]", 

1044 default=True, 

1045 ) 

1046 else: 

1047 allow = confirm( 

1048 "Would you like to authorize edwh to run sudo commands without password entry? [yN]", 

1049 default=False, 

1050 ) 

1051 

1052 if allow: 

1053 # if yes: add to keyring 

1054 if sudo_pass := prompt_validate_sudo_pass(c): 

1055 keyring.set_password("edwh", "sudo", sudo_pass) 

1056 else: 

1057 exit(1) 

1058 

1059 else: 

1060 # else: remove from keyring 

1061 keyring.delete_password("edwh", "sudo") 

1062 

1063 

1064@task( 

1065 pre=[require_sudo], 

1066 help={ 

1067 "new_config_toml": "Remove existing config.toml and create a fresh one", 

1068 "from_env": "Read configuration values from environment variables instead of prompting " 

1069 "(forces non-interactive mode)", 

1070 "non_interactive": "Skip all interactive prompts; fail if required configuration values are missing", 

1071 }, 

1072 hookable=True, 

1073) 

1074def setup( 

1075 c: Context, 

1076 new_config_toml: bool = False, 

1077 _retry: bool = False, 

1078 from_env: bool = False, 

1079 non_interactive: bool = False, 

1080) -> dict: 

1081 """ 

1082 Sets up config.toml and tries to run setup in local tasks.py if it exists 

1083 """ 

1084 config_toml = Path(DEFAULT_TOML_NAME) 

1085 dc_path = Path("docker-compose.yml") 

1086 

1087 if from_env: 1087 ↛ 1088line 1087 didn't jump to line 1088 because the condition on line 1087 was never true

1088 os.environ["EDWH_NON_INTERACTIVE"] = "1" 

1089 os.environ["EDWH_FROM_ENV"] = "1" 

1090 elif non_interactive: 1090 ↛ 1091line 1090 didn't jump to line 1091 because the condition on line 1090 was never true

1091 os.environ["EDWH_NON_INTERACTIVE"] = "1" 

1092 

1093 if ( 1093 ↛ 1101line 1093 didn't jump to line 1101 because the condition on line 1093 was never true

1094 new_config_toml 

1095 and config_toml.exists() 

1096 and confirm( 

1097 colored(f"Are you sure you want to remove the {DEFAULT_TOML_NAME}? [yN]", "red"), 

1098 default=False, 

1099 ) 

1100 ): 

1101 config_toml.unlink() 

1102 

1103 copy_fallback_toml(force=False) # only if .toml is missing, try to copy default.toml 

1104 

1105 if dc_path.exists(): 1105 ↛ 1118line 1105 didn't jump to line 1118 because the condition on line 1105 was always true

1106 print("getting services...") 

1107 

1108 try: 

1109 # run `docker compose config` to build a yaml with all processing done, include statements included. 

1110 build_toml(c) 

1111 except Exception as e: 

1112 cprint( 

1113 f"Something went wrong trying to create a {DEFAULT_TOML_NAME} from docker-compose.yml ({e})", 

1114 color="red", 

1115 ) 

1116 # this could be because 'include' requires a variable that's setup in local task, so still run that: 

1117 else: 

1118 cprint("docker-compose file is missing, setup might not be completed properly!", color="yellow") 

1119 

1120 # local/plugin setup happens here because of `hookable` 

1121 return {} 

1122 

1123 

1124@task() 

1125def search_adjacent_setting(c: Context, key: str, silent: bool = False) -> AnyDict: 

1126 """ 

1127 Search for key in all ../*/.env files. 

1128 """ 

1129 key = key.upper() 

1130 if not silent: 

1131 print("search for ", key) 

1132 envs = (pathlib.Path(c.cwd) / "..").glob("*/.env") 

1133 adjacent_settings = {} 

1134 for env_path in envs: 

1135 value = read_dotenv(env_path).get(key) 

1136 project = env_path.parent.name 

1137 if not silent: 

1138 print(f"{project:>20} : {value}") 

1139 adjacent_settings[project] = value 

1140 return adjacent_settings 

1141 

1142 

1143def next_value(c: Context, key: list[str] | str, lowest: int, silent: bool = True) -> int: 

1144 """Find all other project settings using key, adding 1 to max of all values, or defaults to lowest. 

1145 

1146 next_value(c, 'REDIS_PORT', 6379) -> might result 6379, or 6381 if this is the third project to be initialised 

1147 next_value(c, ['PGPOOL_PORT','POSTGRES_PORT','PGBOUNCER_PORT'], 5432) -> finds the next port searching for all keys. 

1148 """ 

1149 keys = [key] if isinstance(key, str) else key 

1150 all_settings: AnyDict = {} 

1151 for key in keys: 

1152 settings = search_adjacent_setting(c, key, silent) 

1153 all_settings |= {f"{k}/{key}": v for k, v in settings.items() if v} 

1154 if not silent: 

1155 print() 

1156 values = {int(v) for v in all_settings.values() if v} 

1157 return max(values) + 1 if any(values) else lowest 

1158 

1159 

1160THREE_WEEKS = 60 * 24 * 7 * 3 

1161 

1162 

1163@task() 

1164def clean_old_sessions(c: Context, relative_glob: str = "web2py/apps/*/sessions", minutes: int = THREE_WEEKS) -> None: 

1165 for directory in Path.cwd().glob(relative_glob): 

1166 c.sudo(f'find "{directory}" -type f -mmin +{minutes} -exec rm -f "{{}}" +;') 

1167 remove_empty_dirs(c, directory) 

1168 

1169 

1170@task() 

1171def remove_empty_dirs(c: Context, path: str | Path) -> None: 

1172 c.sudo(f'find "{path}" -type d -exec rmdir --ignore-fail-on-non-empty {{}} +') 

1173 

1174 

1175def set_permissions( 

1176 c: Context, 

1177 path: str, 

1178 uid: int = 1050, 

1179 gid: int = 1050, 

1180 filepermissions: int = 664, 

1181 directorypermissions: int = 775, 

1182) -> None: 

1183 """ 

1184 Set all directories in path to 'directorypermissions', 

1185 all files to 'filepermissions' 

1186 and chown the right user+group. 

1187 """ 

1188 # sudo(f'find "{path}" -type d -print0 | sudo xargs --no-run-if-empty -0 chmod {directorypermissions}') 

1189 c.sudo(f'find "{path}" -type d -exec chmod {directorypermissions} {{}} +') 

1190 # find all files, print the output, feed those to xargs which converts lines in to arguments to the chmod command. 

1191 # sudo(f'find "{path}" -type f -print0 | sudo xargs --no-run-if-empty -0 chmod {filepermissions}') 

1192 c.sudo(f'find "{path}" -type f -exec chmod {filepermissions} {{}} +') 

1193 # simply apply new ownership to each and every directory 

1194 c.sudo(f'chown -R {uid}:{gid} "{path}" ') 

1195 

1196 

1197@task(help=dict(silent="do not echo the password")) 

1198def generate_password(_: Context, silent: bool = False, dice: int = 6) -> str: 

1199 """ 

1200 Generate a diceware password using --dice 6. 

1201 

1202 Arggs: 

1203 _: invoke Context 

1204 silent: don't print the generated password? 

1205 dice: amount of words to generate 

1206 

1207 """ 

1208 return _generate_password(silent=silent, dice=dice) 

1209 

1210 

1211def fuzzy_match(val1: str, val2: str, verbose: bool = False) -> float: 

1212 """ 

1213 Get the similarity score between two values. 

1214 

1215 Used by `edwh settings -f ...` when no exact match was found. 

1216 """ 

1217 similarity = fuzz.partial_ratio(val1, val2) 

1218 if verbose: 

1219 print(f"similarity of {val1} and {val2} is {similarity}", file=sys.stderr) 

1220 return similarity 

1221 

1222 

1223def _settings(find: t.Optional[str], fuzz_threshold: int = 75) -> t.Iterable[tuple[str, t.Any]]: 

1224 all_settings = read_dotenv().items() 

1225 if find is None: 

1226 # don't loop 

1227 return all_settings 

1228 else: 

1229 find = find.upper() 

1230 # if nothing found exactly, try again but fuzzy (could be slower) 

1231 exact_match = [(k, v) for k, v in all_settings if find in k.upper() or find in v.upper()] 

1232 return exact_match or [(k, v) for k, v in all_settings if fuzzy_match(k.upper(), find) > fuzz_threshold] 

1233 

1234 

1235# noinspection PyUnusedLocal 

1236@task( 

1237 help=dict(find="search for this specific setting", as_json="output as json dictionary"), 

1238 flags={ 

1239 "as_json": ("j", "json", "as-json"), 

1240 "fuzz_threshold": ("t", "fuzz-threshold"), 

1241 }, 

1242) 

1243def settings(_: Context, find: Optional[str] = None, fuzz_threshold: int = 75, as_json: bool = False) -> None: 

1244 """ 

1245 Show all settings in .env file or search for a specific setting using -f/--find. 

1246 """ 

1247 rows = _settings(find, fuzz_threshold) 

1248 if as_json: 

1249 print(json.dumps(dict(rows), indent=3)) 

1250 else: 

1251 print(tabulate.tabulate(rows, headers=["Setting", "Value"])) 

1252 

1253 

1254def show_related_settings(ctx: Context, services: list[str]) -> None: 

1255 config = dc_config(ctx) 

1256 

1257 rows: AnyDict = {} 

1258 for service in services: 

1259 if service_settings := _settings(service): 

1260 rows |= service_settings 

1261 else: 

1262 with contextlib.suppress(TypeError, KeyError): 

1263 rows |= config["services"][service]["environment"] 

1264 

1265 print(tabulate.tabulate(rows.items(), headers=["Setting", "Value"])) 

1266 

1267 

1268@task(aliases=("volume",)) 

1269def volumes(ctx: Context) -> None: 

1270 """ 

1271 Show container and volume names. 

1272 

1273 Based on `docker-compose ps -q` ids and `docker inspect` output. 

1274 """ 

1275 lines: list[AnyDict] = [] 

1276 ran = ctx.run(f"{DOCKER_COMPOSE} ps -q", hide=True, warn=True) 

1277 stdout = ran.stdout if ran else "" 

1278 for container_id in stdout.strip().split("\n"): 

1279 with contextlib.suppress(EnvironmentError): 

1280 docker_info = docker_inspect(ctx, container_id) 

1281 if not isinstance(docker_info, list): 

1282 continue 

1283 

1284 info = docker_info[0] 

1285 container = info["Name"] 

1286 lines.extend( 

1287 dict(container=container, volume=volume) 

1288 for volume in [_["Name"] for _ in info["Mounts"] if _["Type"] == "volume"] 

1289 ) 

1290 

1291 print(tabulate.tabulate(lines, headers="keys")) 

1292 

1293 

1294def check_paused(ctx: Context, service: str) -> bool: 

1295 """Check if a service container is paused.""" 

1296 result = ctx.run(f"{DOCKER_COMPOSE} ps --format json {service}", hide=True, warn=True) 

1297 try: 

1298 container_info = json.loads(result.stdout.strip()) 

1299 if isinstance(container_info, list): 

1300 container_info = container_info[0] if container_info else {} 

1301 

1302 state = container_info.get("State", "") 

1303 return state == "paused" 

1304 except (json.JSONDecodeError, KeyError, IndexError): 

1305 return False 

1306 

1307 

1308def get_service_dependencies(ctx: Context, service: str) -> list[str]: 

1309 """Get the dependencies (depends_on) for a service from docker-compose config.""" 

1310 result = ctx.run(f"{DOCKER_COMPOSE} config --format json", hide=True, warn=True) 

1311 try: 

1312 config = json.loads(result.stdout.strip()) 

1313 services = config.get("services", {}) 

1314 service_config = services.get(service, {}) 

1315 depends_on: dict[str, t.Any] = service_config.get("depends_on", {}) 

1316 

1317 # depends_on can be a list or a dict 

1318 if isinstance(depends_on, dict): 

1319 return list(depends_on.keys()) 

1320 elif isinstance(depends_on, list): 

1321 return depends_on 

1322 return [] 

1323 except (json.JSONDecodeError, KeyError) as e: 

1324 print(f"Could not get dependencies for {service}: {e}") 

1325 return [] 

1326 

1327 

1328def get_paused_services_with_deps(ctx: Context, services: list[str]) -> list[str]: 

1329 """Get all paused services including their dependencies. 

1330 

1331 Args: 

1332 ctx: Invoke context 

1333 services: List of service names to check 

1334 

1335 Returns: 

1336 List of paused service names (including dependencies) 

1337 """ 

1338 # Collect all services including dependencies 

1339 all_services = set(services) 

1340 for service in services: 

1341 dependencies = get_service_dependencies(ctx, service) 

1342 all_services.update(dependencies) 

1343 

1344 # Check which services are paused 

1345 return [svc for svc in all_services if check_paused(ctx, svc)] 

1346 

1347 

1348# noinspection PyShadowingNames 

1349 

1350 

1351@task( 

1352 help=dict( 

1353 service="Service to up, defaults to .toml's [services].minimal. Can be used multiple times, handles wildcards.", 

1354 build="request a build be performed first", 

1355 quickest="restart only, no down;up", 

1356 stop_timeout="timeout for stopping services, defaults to 2 seconds", 

1357 tail="tails the log of restart services, defaults to False", 

1358 clean="adds `--renew-anon-volumes --build` to `docker-compose up` command ", 

1359 ), 

1360 iterable=["service"], 

1361 flags={ 

1362 "tail": ("tail", "logs", "l"), # instead of -a; NOTE: 'tail' must be first (matches parameter name) 

1363 }, 

1364 hookable=True, 

1365) 

1366def up( 

1367 ctx: Context, 

1368 service: t.Collection[str] | None = None, 

1369 no_build: bool = False, 

1370 quickest: bool = False, 

1371 stop_timeout: int = 2, 

1372 tail: bool = False, 

1373 clean: bool = False, 

1374 show_settings: bool = True, 

1375 wait: bool = False, 

1376) -> dict: 

1377 """Restart (or down;up) some or all services, after an optional rebuild.""" 

1378 config = TomlConfig.load() 

1379 # recalculate the hash and save it, so with the next up, migrate will see differences and start migration 

1380 set_env_value(DEFAULT_DOTENV_PATH, "SCHEMA_VERSION", calculate_schema_hash()) 

1381 # test for --service arguments, if none given: use defaults 

1382 services = service_names(service or (config.services_minimal if config else [])) 

1383 services_ls = " ".join(services) 

1384 

1385 # Check for paused containers and unpause them 

1386 if paused_services := get_paused_services_with_deps(ctx, services): 

1387 paused_ls = " ".join(paused_services) 

1388 cprint(f"Unpausing and stopping services: {paused_ls}", "blue") 

1389 ctx.run(f"{DOCKER_COMPOSE} unpause {paused_ls}", pty=True) 

1390 # unpaused containers often get unhealthy so also stop them: 

1391 ctx.run(f"{DOCKER_COMPOSE} stop {paused_ls}", pty=True) 

1392 

1393 if quickest: 

1394 ctx.run(f"{DOCKER_COMPOSE} restart {services_ls}") 

1395 else: 

1396 ctx.run(f"{DOCKER_COMPOSE} stop -t {stop_timeout} {services_ls}") 

1397 # note: checking if build is required due to outdated versions seems undoable, docker has no api for it 

1398 # so we're just adding --build. There also is no --pull, so you need to run ew build or dc pull manually 

1399 

1400 ctx.run( 

1401 f"{DOCKER_COMPOSE} up " 

1402 f"{'--renew-anon-volumes' if clean else ''} " 

1403 f"{'' if no_build else '--build'} " 

1404 f"-d {services_ls}", 

1405 pty=True, 

1406 ) 

1407 

1408 if show_settings: 

1409 show_related_settings(ctx, services) 

1410 if tail: 

1411 ctx.run(f"{DOCKER_COMPOSE} logs --tail=10 -f {services_ls}") 

1412 if wait: 

1413 health(ctx, services, wait=True) 

1414 

1415 # local/plugin up happens here because of `hookable` 

1416 return { 

1417 "services": services, 

1418 } 

1419 

1420 

1421@task(aliases=("health-inspect",)) 

1422def inspect_health(ctx, container: str, quiet: bool = False) -> dict: 

1423 tab = " " * 2 

1424 result = {} 

1425 

1426 with contextlib.suppress(OSError): 

1427 container_ids = find_container_ids(ctx, container) or [container] 

1428 

1429 for container_id in container_ids: 

1430 result[container_id] = docker_inspect(ctx, container_id, '--format "{{json .State.Health }}"') 

1431 

1432 if result and not quiet: 

1433 print(tab + yaml.dump(result, allow_unicode=True).replace("\n", f"\n{tab}")) 

1434 

1435 return result 

1436 

1437 

1438@task( 

1439 iterable=("service",), 

1440) 

1441def wait_until_healthy(ctx: Context, services: t.Iterable[str] = (), quiet: bool = False): 

1442 initial_length = 0 

1443 # for every container with a health check, wait for it to be either healthy or dead (not starting) 

1444 while missing := [_.container for _ in get_healths(ctx, *services) if _ and _.health == "starting"]: 

1445 if not quiet: 

1446 msg = f" Waiting for {missing}" + " " * 25 

1447 if not initial_length: 

1448 initial_length = len(msg) 

1449 

1450 print(msg, end="\r") 

1451 

1452 # wait is done, now print empty line to cleanup print traces: 

1453 if initial_length and not quiet: 

1454 print(" " * initial_length) 

1455 

1456 return 0 

1457 

1458 

1459@task( 

1460 flags={ 

1461 "show_all": ("all", "a"), 

1462 }, 

1463 iterable=("service",), 

1464) 

1465def health( 

1466 ctx: Context, 

1467 service: t.Collection[str] | None = None, 

1468 wait: bool = False, 

1469 show_all: bool = False, 

1470 quiet: bool = False, 

1471 verbose: bool = False, 

1472) -> int: 

1473 """ 

1474 Show health status for docker containers 

1475 

1476 Args: 

1477 ctx: invoke context 

1478 service: which services to show logs for. 

1479 If you have a 'health' section in your .toml, those services will be used by default. 

1480 Otherwise, 'all' will be used by default. 

1481 wait: should the command wait until all services are healthy? Defaults to only showing status once and exiting. 

1482 show_all: show all services. Alias for `-s all` 

1483 quiet: don't print anything, only return amount of unhealthy containers 

1484 verbose: print health inspection for unhealthy containers 

1485 

1486 Returns: 

1487 Number of unhealthy services (0 is good, just like bash exit codes). 

1488 Should always be 0 if you use --wait 

1489 """ 

1490 config = TomlConfig.load() 

1491 # test for --service arguments, if none given: use defaults 

1492 if show_all: 

1493 services = service_names("all") 

1494 elif service: 

1495 services = service_names(service) 

1496 elif config: 

1497 services = service_names(config.services_health or config.all_services) 

1498 else: 

1499 services = [] 

1500 

1501 if wait: 

1502 return wait_until_healthy(ctx, services, quiet=quiet) 

1503 

1504 healths = get_healths(ctx, *services) 

1505 if not quiet: 

1506 for health_status in sorted((_ for _ in healths if _ is not None), key=lambda h: (h.level, h.container)): 

1507 print(f"- {health_status}") 

1508 if verbose and not health_status.ok and health_status.container_id: 

1509 inspect_health(ctx, health_status.container_id) 

1510 

1511 # return amount of sick containers: 

1512 return sum(not _.ok for _ in healths) 

1513 

1514 

1515class DockerProject(t.TypedDict): 

1516 count: int 

1517 container_statuses: list[str] 

1518 

1519 

1520@task(aliases=("psa",)) 

1521def ps_all(ctx): 

1522 """ 

1523 Show Docker Compose projects with container counts and summarized status. 

1524 """ 

1525 result = ctx.run("docker ps --format '{{json .}}'", hide=True) 

1526 lines = [line.strip() for line in result.stdout.splitlines() if line.strip()] 

1527 

1528 projects: dict[str, DockerProject] = defaultdict(lambda: {"count": 0, "container_statuses": []}) 

1529 

1530 for line in lines: 

1531 container_data = json.loads(line) 

1532 container_name = container_data["Names"] 

1533 container_state = container_data.get("State", "") 

1534 container_status_text = container_data.get("Status", "").lower() 

1535 

1536 project_name = container_name.split("-")[0] 

1537 projects[project_name]["count"] += 1 

1538 

1539 # Determine container status with priority: unhealthy > paused > healthy/ok > others 

1540 if "unhealthy" in container_status_text or "health: starting" in container_status_text: 

1541 container_status = "unhealthy" 

1542 elif "paused" in container_state: 

1543 container_status = "paused" 

1544 elif "healthy" in container_status_text: 

1545 container_status = "healthy" 

1546 elif container_state == "running": 

1547 container_status = "ok" 

1548 else: 

1549 container_status = container_state 

1550 

1551 projects[project_name]["container_statuses"].append(container_status) 

1552 

1553 table_rows = [] 

1554 for project_name, info in sorted(projects.items()): 

1555 if not isinstance(info, dict): 

1556 continue 

1557 

1558 statuses_set = set(info["container_statuses"]) 

1559 

1560 if "unhealthy" in statuses_set: 

1561 project_status = "unhealthy" 

1562 elif "paused" in statuses_set: 

1563 project_status = "paused" 

1564 # The <= operator for sets checks if statuses_set is a subset of {"healthy", "ok"} 

1565 # i.e., all containers are either healthy or ok 

1566 elif statuses_set <= {"healthy", "ok"}: 

1567 project_status = "ok" 

1568 elif len(statuses_set) > 1: 

1569 project_status = "mixed" 

1570 else: 

1571 project_status = next(iter(statuses_set)) 

1572 

1573 table_rows.append((project_name, info["count"], project_status)) 

1574 

1575 print(tabulate.tabulate(table_rows, headers=["Project", "Containers", "Status"], tablefmt="pipe")) 

1576 

1577 

1578@task( 

1579 iterable=["service", "columns"], 

1580 help=dict( 

1581 service="Service to query, can be used multiple times, handles wildcards.", 

1582 quiet="Only show container ids. Useful for scripting.", 

1583 columns="Which columns to display?", 

1584 full="Don't truncate the command.", 

1585 ), 

1586 flags={ 

1587 "show_all": ("all", "a"), 

1588 }, 

1589) 

1590def ps( 

1591 ctx: Context, 

1592 quiet: bool = False, 

1593 service: t.Collection[str] | None = None, 

1594 columns: t.Collection[str] | None = None, 

1595 full: bool = False, 

1596 show_all: bool = False, 

1597) -> None: 

1598 """ 

1599 Show process status of services. 

1600 """ 

1601 trunc_after = 30 

1602 if not Path("docker-compose.yml").exists(): 

1603 cprint("You're not in a docker compose environment.", color="red") 

1604 if confirm("Would you like to see all running environments? [Yn]", default=True): 

1605 ps_all(ctx) 

1606 return 

1607 

1608 flags = [] 

1609 

1610 if show_all: 

1611 flags.append("-a") 

1612 if quiet: 

1613 flags.append("-q") 

1614 

1615 # we may trunc it ourselves: 

1616 flags.append("--no-trunc") 

1617 

1618 flags.extend(service_names(service or [])) 

1619 

1620 args_str = " ".join(flags) 

1621 

1622 ran = ctx.run( 

1623 f"{DOCKER_COMPOSE} ps --format json {args_str}", 

1624 warn=True, 

1625 hide=True, 

1626 ) 

1627 ps_output = ran.stdout.strip() if ran else "" 

1628 

1629 services = [] 

1630 

1631 # list because it's ordered 

1632 selected_columns = list(columns or []) or ["Name", "Command", "Image", "State", "Health", "Ports"] 

1633 

1634 for service_json in ps_output.split("\n"): 

1635 if not service_json: 

1636 # empty line 

1637 continue 

1638 

1639 service_dict = json.loads(service_json) 

1640 service_dict = {k: v for k, v in service_dict.items() if k in selected_columns} 

1641 if not full: 

1642 service_dict["Command"] = shorten(service_dict["Command"], trunc_after) 

1643 service_dict["Image"] = shorten(service_dict["Image"], trunc_after) 

1644 

1645 service_dict = dict(sorted(service_dict.items(), key=lambda x: selected_columns.index(x[0]))) 

1646 services.append(service_dict) 

1647 

1648 print(tabulate.tabulate(services, headers="keys")) 

1649 

1650 

1651@task( 

1652 help=dict( 

1653 quiet="Only show ids (mostly directories). Useful for scripting.", 

1654 ), 

1655) 

1656def ls(ctx: Context, quiet: bool = False) -> None: 

1657 """ 

1658 List running compose projects. 

1659 """ 

1660 ctx.run(f"{DOCKER_COMPOSE} ls {'-q' if quiet else ''}") 

1661 

1662 

1663def get_docker_info(ctx: Context, services: list[str]) -> dict[str, AnyDict]: 

1664 """ 

1665 Return a dict of {id: service} 

1666 """ 

1667 # -aq doesn't keep the same order of services, so use json format to get ID with service name. 

1668 # use --no-trunc to get full ID instead of short one 

1669 if ran := ctx.run(f"{DOCKER_COMPOSE} ps --format json --no-trunc -a {' '.join(services)}", hide=True): 

1670 rows = ran.stdout 

1671 else: 

1672 rows = "" 

1673 

1674 result = {} 

1675 

1676 for line in rows.split("\n"): 

1677 if not line: 

1678 continue 

1679 

1680 # each line contains one json object 

1681 info = json.loads(line) 

1682 

1683 result[info["ID"]] = info 

1684 

1685 return result 

1686 

1687 

1688T_Stream = t.Literal["stdout", "stderr", "out", "err", ""] 

1689 

1690 

1691def follow_logs( 

1692 ctx: Context, 

1693 container_id: str, 

1694 project: str, 

1695 longest_name: int, 

1696 color: ColorFn, 

1697 since: str | None, 

1698 verbose: bool, 

1699 timestamps: bool, 

1700 stream: T_Stream = "", 

1701 filter_pattern: str = "", 

1702 stop_event: threading.Event | None = None, 

1703) -> bool: 

1704 """ 

1705 Follows logs of a specified Docker container while optionally filtering and formatting output. 

1706 

1707 This function actively monitors the logs of a given container, allowing for custom filtering 

1708 through a regular expression pattern and handling streams like `stdout` or `stderr`. It is 

1709 designed to handle specific options such as including timestamps or verbose prefixes, and it 

1710 can restart the log retrieval process in case of certain interruptions. 

1711 

1712 Parameters: 

1713 ctx (Context): Execution context used to run commands. 

1714 container_id (str): ID of the container whose logs are being followed. 

1715 project (str): Project or prefix name associated with the container. 

1716 longest_name (int): Length of the longest name used for alignment in output. 

1717 color (ColorFn): Function to apply color formatting to output, typically the container name. 

1718 since (str | None): Initial timestamp or date from which logs should be retrieved, if available. 

1719 verbose (bool): Whether to include verbose prefixes (e.g., stream type) in output. 

1720 timestamps (bool): Whether to include timestamps from the logs in the output. 

1721 stream (Literal["stdout", "stderr", "out", "err", ""]): Stream type to handle in the output. 

1722 An empty string ("") implies both streams will be followed. 

1723 filter_pattern (str): Regular expression pattern used to filter log entries. Defaults to an 

1724 empty string, meaning no filtering is applied. 

1725 

1726 Returns: 

1727 bool: True if the log following process terminates validly, False otherwise. 

1728 

1729 Raises: 

1730 None explicitly defined, but will handle `KeyboardInterrupt` gracefully during execution. 

1731 """ 

1732 if stream not in t.get_args(T_Stream): 

1733 raise ValueError(f"Invalid stream value: '{stream}'.") 

1734 

1735 # Get container name for prefix 

1736 name_result = ctx.run( 

1737 "docker inspect --format='{{.Name}}' %(container)s" % {"container": container_id}, 

1738 hide=True, 

1739 warn=True, 

1740 ) 

1741 

1742 container_name = name_result.stdout.strip().lstrip("/") 

1743 container_name = container_name.removeprefix(f"{project}-").ljust(longest_name + 3, " ") 

1744 

1745 prefix = color(f"{container_name} | ") 

1746 

1747 re_filter_fn = parse_regex(filter_pattern) if filter_pattern else None 

1748 

1749 stdout_handler = ( 

1750 LineBufferHandler(f"{prefix}out | " if verbose else prefix, sys.stdout, filter_fn=re_filter_fn) 

1751 if stream in ("out", "stdout", "") 

1752 else NoopHandler() 

1753 ) 

1754 stderr_handler = ( 

1755 LineBufferHandler(f"{prefix}err | " if verbose else prefix, sys.stderr, filter_fn=re_filter_fn) 

1756 if stream in ("out", "stdout", "") 

1757 else NoopHandler() 

1758 ) 

1759 

1760 # Loop until container state is 'exited' 

1761 process: Optional[Promise] = None 

1762 runner: Optional[Runner] = None 

1763 while True: 

1764 try: 

1765 # Check container state 

1766 result = ctx.run( 

1767 "docker inspect --format='{{.State.Status}}' %(container)s" % {"container": container_id}, 

1768 hide=True, 

1769 warn=True, 

1770 ) 

1771 

1772 if result.failed: 

1773 # machine is dead 

1774 return False 

1775 

1776 # Follow logs with timestamps, starting from last_timestamp if available 

1777 args = ["docker", "logs", "--follow", container_id] 

1778 if timestamps: 

1779 args.append("--timestamps") 

1780 

1781 # If we have a last timestamp, use it to start from where we left off 

1782 if since: 

1783 args.extend(("--since", since)) 

1784 

1785 # Run the docker logs command with our watcher 

1786 cmd = shlex.join(args) 

1787 process = ctx.run(cmd, pty=True, warn=True, asynchronous=True) 

1788 runner = process.runner 

1789 

1790 try: 

1791 while not runner.process_is_finished: 

1792 # start loop - check for stop event 

1793 if stop_event and stop_event.is_set(): 

1794 return True 

1795 

1796 while runner.stdout: 

1797 stdout_handler.process(runner.stdout.pop(0)) 

1798 

1799 while runner.stderr: 

1800 stderr_handler.process(runner.stderr.pop(0)) 

1801 

1802 time.sleep(0.1) 

1803 except ChildProcessError: 

1804 # --since <datetime> includes rows at that datetime so `dt.timedelta(microseconds=1)` is added: 

1805 since = (dt.datetime.now() + dt.timedelta(microseconds=1)).isoformat() 

1806 time.sleep(0.1) 

1807 continue 

1808 

1809 except KeyboardInterrupt: 

1810 # Cancel the process if it's still running 

1811 if runner and not runner.process_is_finished: 

1812 runner.stop() 

1813 runner.kill() 

1814 return True 

1815 

1816 # idk how we got here 

1817 return False 

1818 

1819 

1820@task( 

1821 aliases=("log",), 

1822 iterable=["service"], 

1823 flags={ 

1824 "show_all": ("all", "a"), 

1825 "filter_pattern": ("filter", "p"), # -p for pattern, -f is already for follow 

1826 }, 

1827 help={ 

1828 "service": "What services to follow. " 

1829 "Defaults to services in the `log` section of `.toml`, can be applied multiple times. ", 

1830 "show_all": "Ignore --service and show all service logs (same as `-s '*'`).", 

1831 "follow": "Keep scrolling with the output (default, use --no-follow or --limit <n> or --sort to disable).", 

1832 "timestamps": "Add timestamps (on by default, use --no-timestamps to disable)", 

1833 "limit": "Start with how many lines of history, don't follow.", 

1834 "sort": "Sort the output by timestamp: forced timestamp and mutual exclusive with follow.", 

1835 "since": "Filter by age (2024-05-03T12:00:00, 1 hour, now); in UTC", 

1836 "new": "Don't show old entries (conflicts with since, same as --since now)", 

1837 "stream": "Filter by stdout/stderr (defaults to both), only used when following", 

1838 "filter_pattern": "Search by term or regex, only used when following", 

1839 "verbose": "Show slightly more info, like full timestamps.", 

1840 }, 

1841) 

1842def logs( 

1843 ctx: Context, 

1844 service: t.Collection[str] | None = None, 

1845 follow: bool = True, 

1846 limit: Optional[int] = None, 

1847 sort: bool = False, 

1848 show_all: bool = False, 

1849 verbose: bool = False, 

1850 timestamps: bool = True, 

1851 since: Optional[str] = None, 

1852 new: bool = False, 

1853 stream: T_Stream = "", 

1854 filter_pattern: str = "", 

1855) -> list[bool]: 

1856 """Smart docker logging""" 

1857 

1858 if new and since: 

1859 raise ValueError("Cannot use --new and --since together") 

1860 if new: 

1861 since = "1s" 

1862 

1863 if sort and follow: 

1864 raise ValueError("--sort is mutually exclusive with following logs") 

1865 

1866 services = service_names([], default="all") if show_all else service_names(service or [], default="logs") 

1867 

1868 if limit or not follow or sort: 

1869 if filter_pattern: 

1870 raise ValueError("--filter is exclusive with --limit, --no-follow and --sort") 

1871 

1872 # use basic logs 

1873 cmdline = [f"{DOCKER_COMPOSE} logs", f"--tail={limit or 500}"] 

1874 cmdline.extend(services) 

1875 if sort or timestamps: 

1876 # add timestamps 

1877 cmdline.append("-t") 

1878 

1879 if sort: 

1880 cmdline.append(r'| sed -E "s/^([^|]*)\|([^Z]*Z)(.*)$/\2|\1|\3/" | sort') 

1881 

1882 if since: 

1883 cmdline.extend(["--since", since]) 

1884 

1885 return [ctx.run(" ".join(cmdline), echo=verbose, pty=True).ok] 

1886 

1887 # else use fancy logs 

1888 

1889 # now find containers for these services: 

1890 # -> `py4web` can map to `py4web-1, py4web-2` etc 

1891 promises: list[futures.Future[bool]] = [] 

1892 colors = rainbow() 

1893 containers = get_docker_info(ctx, services) 

1894 

1895 if not containers: 

1896 cprint(f"No running containers found for services {services}", color="red") 

1897 exit(1) 

1898 elif len(containers) != len(services): 

1899 cprint("Amount of requested services does not match the amount of running containers!", color="yellow") 

1900 

1901 # for adjusting the | location 

1902 longest_name = max([len(_["Service"]) for _ in containers.values()]) 

1903 

1904 with futures.ThreadPoolExecutor() as executor: 

1905 stop_event = threading.Event() 

1906 

1907 for service in services: 

1908 for container_id in ctx.run(f"{DOCKER_COMPOSE} ps -aq {service}", hide=True).stdout.split("\n"): 

1909 if not (container_info := containers.get(container_id)): 

1910 # empty or whitespace only 

1911 continue 

1912 

1913 future = executor.submit( 

1914 follow_logs, 

1915 ctx, 

1916 container_id, 

1917 container_info["Project"], 

1918 longest_name, 

1919 next(colors), 

1920 since, 

1921 verbose, 

1922 timestamps, 

1923 stream, 

1924 filter_pattern, 

1925 stop_event, 

1926 ) 

1927 promises.append(future) 

1928 

1929 # Wait for all futures to complete 

1930 # This mimics the original join_all behavior to return the list of results 

1931 try: 

1932 return [promise.result() for promise in promises] 

1933 except KeyboardInterrupt: 

1934 print("Ctrl-C pressed, stopping log threads...") 

1935 stop_event.set() 

1936 return [] 

1937 

1938 

1939def start_logs(c: Context, service: t.Collection[str] | None = None, args: str = ""): 

1940 """ 

1941 Normal edwh logs can't just be called with `edwh.tasks.logs` so this wrapper makes it easier. 

1942 

1943 (otherwise `logs` will try to elevate permissions and get confused due to not being called directly) 

1944 """ 

1945 service = service_names(service or ()) 

1946 

1947 args = " ".join(f"-s {s}" for s in service) + f" {args}" 

1948 

1949 return c.run(f"edwh logs {args}", pty=True) 

1950 

1951 

1952@task( 

1953 iterable=["service"], 

1954 help=dict(service="Service to stop, can be used multiple times, handles wildcards."), 

1955 hookable=True, 

1956) 

1957def stop(ctx: Context, service: t.Collection[str] | None = None) -> None: 

1958 """ 

1959 Stops services using docker-compose stop. 

1960 """ 

1961 service = service_names(service or []) 

1962 ctx.run(f"{DOCKER_COMPOSE} stop {' '.join(service)}") 

1963 

1964 

1965@task( 

1966 iterable=["service"], 

1967 help=dict(service="Service to stop, can be used multiple times, handles wildcards."), 

1968 hookable=True, 

1969) 

1970def down(ctx: Context, service: t.Collection[str] | None = None) -> None: 

1971 """ 

1972 Stops services using docker-compose down. 

1973 """ 

1974 service = service_names(service or []) if service else [] 

1975 

1976 ctx.run(f"{DOCKER_COMPOSE} down {' '.join(service)}", pty=True) 

1977 

1978 

1979@task( 

1980 iterable=["service"], 

1981) 

1982def restart(c: Context, service: t.Collection[str] | None = None, quiet: bool = False, force: bool = False): 

1983 """ 

1984 Restart Docker services by sending termination signals (ctrl-c/SIGINT; if force: SIGKILL, SIGTERM). 

1985 

1986 Defaults to 'py4web' if no services specified. 

1987 Shows logs unless 'quiet' flag is enabled. 

1988 

1989 Arguments: 

1990 c (Context): The execution context. 

1991 service (Collection[str], optional): A collection of service names to restart. Defaults to restarting 

1992 the 'py4web' service. 

1993 quiet (bool): A flag indicating whether to suppress logs display after restarting services. 

1994 force: send SIGKILL + SIGTERM instead of SIGINT 

1995 

1996 Raises: 

1997 Executes a system command to restart the desired services. 

1998 Should be used within an environment that supports thisfunctionality. 

1999 

2000 Returns: 

2001 None 

2002 """ 

2003 service = service_names(service or ["py4web"]) 

2004 

2005 service_filter = "|".join(service) 

2006 kill = "kill -15 1 || kill -9 1" if force else "kill -2 1" 

2007 command = ( 

2008 'docker ps --filter "name=%(name)s" --format "{{.Names}}" | ' 

2009 'xargs -I {} docker exec {} sh -c "%(kill)s"' % dict(name=service_filter, kill=kill) 

2010 ) 

2011 

2012 c.run(command) 

2013 

2014 if not quiet: 

2015 start_logs(c, service) 

2016 

2017 

2018@task( 

2019 hookable=True, 

2020) 

2021def upgrade(ctx: Context, build: bool = False) -> None: 

2022 if build: 

2023 ctx.run(f"{DOCKER_COMPOSE} build") 

2024 else: 

2025 ctx.run(f"{DOCKER_COMPOSE} pull") 

2026 stop(ctx) 

2027 ctx.run(f"{DOCKER_COMPOSE} up -d") 

2028 

2029 

2030@task( 

2031 help=dict( 

2032 yes="Don't ask for confirmation, just do it. " 

2033 "(unless requirements.in files are found and the `edwh-pipcompile-plugin` is not installed)", 

2034 skip_compile="Skip the compilation of requirements.in files to requirements.txt files (e.g. for PRD).", 

2035 ), 

2036 hookable=True, 

2037) 

2038def build(ctx: Context, yes: bool = False, skip_compile: bool = False, pull: bool = True) -> None: 

2039 """ 

2040 Build all services. 

2041 

2042 Will test for the presence of `edwh-pipcompile-plugin` and use it to compile 

2043 requirements.in files to requirements.txt files in child directories. 

2044 """ 

2045 # Path.cwd() uses absolute paths, Path() is the same but relative 

2046 reqs = list(Path().rglob("*/*.in")) 

2047 

2048 if pip_compile := get_task(ctx, "pip.compile"): 

2049 with_compile = not skip_compile 

2050 else: 

2051 with_compile = False 

2052 

2053 cprint("`edwh-pipcompile-plugin` not found, unable to compile requirements.in files.", "red") 

2054 cprint("💡 Install with `edwh plugin.add pipcompile`", "blue") 

2055 print() 

2056 print("Possible files to compile:") 

2057 for req in reqs: 

2058 print(" * ", req) 

2059 

2060 if not (state_of_development := get_env_value("STATE_OF_DEVELOPMENT", "")): 

2061 cprint("Warning: No SOD found. Add STATE_OF_DEVELOPMENT to the .env file", "yellow") 

2062 

2063 is_dev = state_of_development == "ONT" 

2064 

2065 if not reqs: 

2066 cprint("No .in files found to compile!", "yellow") 

2067 elif with_compile and pip_compile is not None and is_dev: 

2068 for idx, req in enumerate(reqs, 1): 

2069 reqtxt = req.parent / "requirements.txt" 

2070 cprint( 

2071 f"{idx}/{len(reqs)}: working on {req}", 

2072 "blue", 

2073 ) 

2074 missing = not reqtxt.exists() 

2075 outdated = not missing and reqtxt.stat().st_ctime < req.stat().st_ctime 

2076 

2077 if missing or outdated: 

2078 print("The .txt file is outdated." if outdated else "requirements.txt doesn't exist.") 

2079 

2080 question = f"compile {req}? [Yn]" 

2081 if outdated: 

2082 question = f"re{question}" # recompile 

2083 

2084 if yes or confirm(question, default=True): 

2085 pip_compile(ctx, str(req.parent)) 

2086 else: 

2087 print("still current") 

2088 else: 

2089 print("Compilation of requirements.in files skipped.") 

2090 

2091 print() 

2092 prompt = "Pull and build docker images? [yN]" if pull else "Build docker images? [yN]" 

2093 

2094 if yes or is_dev or confirm(prompt, default=False): 

2095 if pull: 

2096 ctx.run(f"{DOCKER_COMPOSE} pull --ignore-buildable", pty=True) 

2097 

2098 ctx.run(f"{DOCKER_COMPOSE} build", pty=True, env=dict(COMPOSE_BAKE="true")) 

2099 

2100 

2101@task( 

2102 help=dict( 

2103 service="Service to rebuild, can be used multiple times, handles wildcards.", 

2104 force_rebuild="uses --no-cache option for docker-compose build", 

2105 ), 

2106 iterable=["service"], 

2107) 

2108def rebuild( 

2109 ctx: Context, 

2110 service: t.Collection[str] | None = None, 

2111 force_rebuild: bool = False, 

2112) -> None: 

2113 """ 

2114 Downs ALL services, then rebuilds services using docker-compose build. 

2115 """ 

2116 ctx.run(f"{DOCKER_COMPOSE} down") 

2117 services = service_names(service) 

2118 

2119 cache_flag = "--no-cache" if force_rebuild else "" 

2120 services_str = " ".join(services) 

2121 ctx.run(f"{DOCKER_COMPOSE} build {cache_flag} {services_str}") 

2122 

2123 

2124@task() 

2125def docs(ctx: Context, reinstall: bool = False) -> bool: 

2126 """ 

2127 Local hosted mkdocs documentation. 

2128 

2129 Installs mkdocs if unavailable. 

2130 """ 

2131 if reinstall: 

2132 print("Installing mkdocs and dependencies...") 

2133 ok = True 

2134 ctx.run("pipx uninstall mkdocs", hide=True, warn=True) 

2135 

2136 ran = ctx.run("pipx install mkdocs", hide=True, warn=True) 

2137 ok &= bool(ran and ran.ok) 

2138 ran = ctx.run( 

2139 "pipx inject mkdocs mkdocs-material plantuml-markdown", 

2140 hide=True, 

2141 warn=True, 

2142 ) 

2143 ok &= bool(ran and ran.ok) 

2144 print("result:", ok) 

2145 return ok 

2146 else: 

2147 ran = ctx.run("mkdocs serve", warn=True) 

2148 if not (ran and ran.ok) and docs(ctx, reinstall=True): 

2149 return docs(ctx) 

2150 

2151 return False 

2152 

2153 

2154# noinspection PyUnusedLocal 

2155@task() 

2156def zen(_: Context) -> None: 

2157 """Prints the Zen of Python""" 

2158 # noinspection PyUnresolvedReferences 

2159 import this # noqa 

2160 

2161 

2162@task() 

2163def whoami(ctx: Context) -> None: 

2164 """ 

2165 Debug method to determine user and host name. 

2166 """ 

2167 ran = ctx.run("whoami", hide=True) 

2168 i_am = ran.stdout.strip() if ran else "" 

2169 

2170 ran = ctx.run("hostname", hide=True) 

2171 my_location = ran.stdout.strip() if ran else "" 

2172 

2173 print(f"{i_am} @ {my_location}") 

2174 

2175 

2176@task() 

2177def completions(_: Context) -> None: 

2178 """ 

2179 Prints the script to enable shell completions. 

2180 """ 

2181 print("Put this in your .bashrc:") 

2182 print("---") 

2183 print('eval "$(edwh --print-completion-script bash)"') 

2184 print("---") 

2185 

2186 

2187def warn_plugin_version_check(active: str, required: str, name: str) -> bool: 

2188 """ 

2189 Warn if a version is too low (using semver-aware sorting). 

2190 """ 

2191 if parse_version(active) < parse_version(required): 

2192 cprint( 

2193 f"Note: your `{name}` tool might be outdated ({active} < {required}). " 

2194 "To prevent weird behavior, try running `edwh self-update`", 

2195 color="yellow", 

2196 file=sys.stderr, 

2197 ) 

2198 return False 

2199 return True 

2200 

2201 

2202@task( 

2203 hookable=True, 

2204) 

2205def version(ctx: Context) -> None: 

2206 """ 

2207 Show edwh app version and docker + compose version. 

2208 """ 

2209 from ewok.__about__ import __version__ as ewok_version 

2210 

2211 print("edwh version", edwh_version) 

2212 print("ewok version", ewok_version) 

2213 print("Python version", sys.version.split(" ")[0]) 

2214 ctx.run("docker --version") 

2215 ctx.run(f"{DOCKER_COMPOSE} version") 

2216 

2217 

2218# for meta tasks such as `plugins` and `self-update`, see meta.py 

2219 

2220 

2221@task( 

2222 name="help", 

2223 help={ 

2224 "about": "Plugin/Namespace or Subcommand you would like to see help about. " 

2225 "Use an empty string ('') to see help about everything." 

2226 }, 

2227) 

2228def show_help(ctx: Context, about: str) -> None: 

2229 """ 

2230 Show helpful information about a plugin or command. 

2231 

2232 Similar to `edwh {about} --help` but that does not work for whole plugins/namespaces. 

2233 """ 

2234 # first check if 'about' is a plugin/namespace: 

2235 if ns := ewok.find_namespace(ctx, about): 

2236 info = ns.serialized() 

2237 

2238 print("--- namespace", ns.name, "---") 

2239 print(info["help"] or "") 

2240 

2241 plugin_commands = [] 

2242 for subtask in info["tasks"]: 

2243 if aliases := subtask["aliases"]: 

2244 aliases = ", ".join(aliases) 

2245 aliases = f"({aliases})" 

2246 else: 

2247 aliases = "" 

2248 

2249 cmd = f"{about}.{subtask['name']}" 

2250 

2251 plugin_commands.append(" ".join([cmd, aliases, "\t", subtask["help"] or ""])) 

2252 

2253 print_aligned(plugin_commands) 

2254 else: 

2255 # just run edwh --help <subcommand>: 

2256 ctx.run(f"edwh --help {about}") 

2257 

2258 

2259@task( 

2260 name="discover", 

2261 help={ 

2262 "du": "Show disk usage per folder", 

2263 "exposes": "Show exposed ports", 

2264 "ports": "Show ports", 

2265 "host_labels": "Show host clauses from traefik labels", 

2266 "short": "Oneline summary", 

2267 "show_settings": "show settings per folder", 

2268 "as_json": "output json", 

2269 }, 

2270 flags={"show_settings": ("settings", "show-settings"), "as_json": ("j", "json", "as-json")}, # -s is for short 

2271) 

2272def task_discover( 

2273 ctx: Context, 

2274 du: bool = False, 

2275 exposes: bool = False, 

2276 ports: bool = False, 

2277 host_labels: bool = True, 

2278 short: bool = False, 

2279 show_settings: bool = False, 

2280 as_json: bool = False, 

2281) -> None: 

2282 """Discover docker environments per host. 

2283 

2284 Use ansi2txt to save readable output to a file. 

2285 """ 

2286 return discover( 

2287 ctx, 

2288 du=du, 

2289 exposes=exposes, 

2290 ports=ports, 

2291 host_labels=host_labels, 

2292 short=short, 

2293 as_json=as_json, 

2294 settings=show_settings, 

2295 ) 

2296 

2297 

2298@task() 

2299def ew_self_update(ctx: Context) -> None: 

2300 """Update edwh to the latest version.""" 

2301 ctx.run("~/.local/bin/edwh self-update") 

2302 ctx.run("~/.local/bin/edwh self-update") 

2303 

2304 

2305@task() 

2306def migrate(ctx: Context, force: bool = False) -> None: 

2307 if force: 

2308 clean_flags(ctx) 

2309 

2310 up(ctx, service=["migrate"], tail=True) 

2311 

2312 

2313@task() 

2314def migrations(ctx: Context) -> None: 

2315 ctx.run(f"{DOCKER_COMPOSE} run --rm migrate migrate --list") 

2316 

2317 

2318def stop_remove_container(ctx: Context, container_name: str) -> bool: 

2319 ran = ctx.run(f"{DOCKER_COMPOSE} rm -vf --stop {container_name}", warn=True) 

2320 return bool(ran and ran.ok) 

2321 

2322 

2323def stop_remove_containers(ctx: Context, *container_names: str) -> list[bool]: 

2324 return [stop_remove_container(ctx, _) for _ in container_names] 

2325 

2326 

2327@task() 

2328def clean_redis(_: Context, db_count: int = 3) -> None: 

2329 import redis as r 

2330 

2331 env = read_dotenv(Path(".env")) 

2332 for db in range(db_count): 

2333 redis_client = r.Redis("localhost", int(env["REDIS_PORT"]), db) 

2334 print(f"Removing {len(redis_client.keys())} keys") 

2335 for key in redis_client: # type: ignore 

2336 del redis_client[key] 

2337 redis_client.close() 

2338 

2339 

2340@task() 

2341def clean_flags(_: Context, flag_dir: str = "migrate/flags"): 

2342 flag_dir_path = pathlib.Path(flag_dir) 

2343 

2344 for flag_file in flag_dir_path.glob("*.complete"): 

2345 print("removing", flag_file) 

2346 flag_file.unlink() 

2347 

2348 

2349@task() 

2350def clean_postgres(ctx: Context, yes: bool = False) -> None: 

2351 # assumes pgpool with pg-0, pg-1 and optionally pg-stats right now! 

2352 yes or confirm( 

2353 "Are you sure you want to wipe the database? This can not be undone [yes,NO]", 

2354 allowed={"yes"}, # strict yes, not just y !!! 

2355 strict=True, # raises RuntimeError 

2356 ) 

2357 

2358 # clear backend flag files 

2359 clean_flags(ctx) 

2360 

2361 config = TomlConfig.load() 

2362 assert config, "Couldn't set up toml config -> can't continue clean!" 

2363 

2364 # find the images based on the instances 

2365 containers = find_containers_ids(ctx, *config.services_db) 

2366 pg_data_volumes = [] 

2367 for container_name, container_ids in containers.items(): 

2368 if not container_ids: 

2369 # probably missing (such as pg-1, pg-stats in some environments) 

2370 continue 

2371 

2372 for container_id in container_ids: 

2373 docker_info = docker_inspect(ctx, container_id) 

2374 if not isinstance(docker_info, list): 

2375 continue 

2376 

2377 info = docker_info[0] 

2378 pg_data_volumes.extend([mount["Name"] for mount in info["Mounts"] if "Name" in mount]) 

2379 

2380 # stop, remove the postgres instances and remove anonymous volumes 

2381 stop_remove_containers(ctx, *containers) 

2382 

2383 # remove images after containers have been stopped and removed 

2384 if pg_data_volumes: 

2385 print("removing", pg_data_volumes) 

2386 ctx.run("docker volume rm " + " ".join(pg_data_volumes), warn=True) 

2387 else: 

2388 cprint("No data volumes to remove!", color="yellow") 

2389 

2390 

2391@task( 

2392 flags={"clean_all": ("all", "a")}, 

2393 hookable=True, 

2394) 

2395def clean( 

2396 ctx: Context, 

2397 clean_all: bool = False, 

2398 db: bool = False, 

2399 postgres: bool = False, 

2400 redis: bool = False, 

2401 yes: bool = False, 

2402) -> None: 

2403 """Rebuild the databases, possibly rebuild microservices. 

2404 

2405 Execution: 

2406 0. build microservices (all, microservices) 

2407 if force_rebuild: 

2408 does not use docker-image cache, thus refreshing even with the same backend version. 

2409 use this is you wish to rebuild the same backend. Easier and faster to use fix: or perf: in the backend... 

2410 1. stopping postgres instances (all, db, postgres) 

2411 2. removing volumes (all, db, postgres) 

2412 3. rebooting postgres instances (all, db, postgres) 

2413 4. ~~purge redis instances (all, redis)~~ IGNORED 

2414 

2415 Removes all ../backend_config/*.complete flags to allow migrate to function properly 

2416 """ 

2417 print("-------------------CLEAN -------------------------") 

2418 if clean_all or db or postgres: 

2419 clean_postgres(ctx, yes=yes) 

2420 

2421 if clean_all or redis: 

2422 clean_redis(ctx) 

2423 

2424 

2425@task(aliases=("whipe-db",), flags={"clean_all": ("all", "a")}) 

2426def wipe_db(ctx: Context, clean_all: bool = False, flag_path: str = "migrate/flags", yes: bool = False) -> None: 

2427 """ 

2428 Wipes postgres volumes. 

2429 Does not start migrate automatically, use `edwh wipe-db migrate up` for a full recovery flow. 

2430 

2431 When using a whitelabel-based environment, 

2432 you may also use `edwh local.recover-devdb` (from ./migrate/data/snapshot) 

2433 """ 

2434 # 1 + 2. just 'create' without starting anything: 

2435 ctx.run(f"{DOCKER_COMPOSE} create") 

2436 

2437 # 3. start cleaning up 

2438 for p in Path(flag_path).glob("migrate-*.complete"): 

2439 p.unlink() 

2440 

2441 clean(ctx, db=True, clean_all=clean_all, yes=yes) 

2442 down(ctx) # remove old containers too 

2443 

2444 

2445@task() 

2446def show_config(_: Context) -> None: 

2447 """ 

2448 Show the current values from .toml after loading. 

2449 """ 

2450 config = TomlConfig.load() 

2451 cprint(f"TomlConfig: {json.dumps(config.__dict__, default=str, indent=2) if config else 'None'}") 

2452 

2453 

2454@task() 

2455def change_config(c: Context) -> None: 

2456 """ 

2457 Change the settings in .toml 

2458 """ 

2459 build_toml(c, overwrite=True) 

2460 

2461 

2462@task() 

2463def debug(_: Context) -> None: 

2464 print(get_env_value("IS_DEBUG", "0")) 

2465 

2466 

2467@task(aliases=("ew",)) 

2468def edwh(_: Context) -> None: 

2469 """ 

2470 Do absolutely nothing. 

2471 

2472 For oopsies like `ew ew up logs` 

2473 """ 

2474 print("Hehe you silly goose", file=sys.stderr) 

2475 

2476 

2477@task() 

2478def sleep(_: Context, n: str) -> None: 

2479 try: 

2480 totaltime = int(n) 

2481 except ValueError as e: 

2482 raise TypeError("`ew sleep <n: int>` requires an amount of seconds to sleep.") from e 

2483 

2484 for remaining in range(totaltime, 0, -1): 

2485 print("\r", f"Sleeping for: {remaining} seconds", end=" ", flush=True) 

2486 time.sleep(1) 

2487 

2488 print("\r", "Sleeping for: 0 seconds", end="\n") 

2489 

2490 

2491def find_ruff() -> str: 

2492 """ 

2493 Use ruff's own logic to find the required binary. 

2494 """ 

2495 from ruff import __main__ as ruff 

2496 

2497 return ruff.find_ruff_bin() 

2498 

2499 

2500def find_ty() -> str: 

2501 """Use Ty's own logic to find the required binary.""" 

2502 from ty import find_ty_bin 

2503 

2504 return find_ty_bin() 

2505 

2506 

2507def enabled_lint_tools() -> dict[str, bool]: 

2508 """Return the enabled lint tools for the current project. 

2509 

2510 Projects can opt out of either tool independently in ``pyproject.toml``: 

2511 

2512 [tool.edwh.lint] 

2513 ruff = false 

2514 ty = false 

2515 """ 

2516 pyproject = Path("pyproject.toml") 

2517 if not pyproject.exists(): 

2518 return {"ruff": True, "ty": True} 

2519 

2520 config = tomllib.loads(pyproject.read_text()) 

2521 lint_config = config.get("tool", {}).get("edwh", {}).get("lint", {}) 

2522 

2523 return {tool: lint_config.get(tool, True) is not False for tool in ("ruff", "ty")} 

2524 

2525 

2526type OutputMode = t.Literal["ci", "cli", "json"] 

2527 

2528 

2529class LintOutput(t.TypedDict): 

2530 mode: OutputMode 

2531 results: dict[str, bool] 

2532 

2533 

2534def _render_lint_result(_: Context, result: LintOutput) -> str | None: 

2535 if not isinstance(result, dict): 

2536 return None 

2537 

2538 if result.get("mode", "cli") != "json": 

2539 return None 

2540 

2541 data = dict(result.get("results", {})) 

2542 

2543 print(json.dumps(data, indent=2, sort_keys=True)) 

2544 

2545 if not all(data.values()): 

2546 raise SystemExit(1) 

2547 

2548 return None 

2549 

2550 

2551def _print_lint_status(tool: str, ok: bool, output: OutputMode) -> None: 

2552 if output == "ci": 

2553 print(f"{'✅' if ok else '❌'} {tool}") 

2554 else: 

2555 color: Color = "green" if ok else "red" 

2556 cprint(f"{tool}", color=color) 

2557 

2558 

2559def run_command_with_output( 

2560 ctx: Context, 

2561 command: str, 

2562 mode: OutputMode = "cli", 

2563 tool: str | None = None, 

2564) -> bool: 

2565 """Run a command, capture its output, and replay it to the current terminal.""" 

2566 

2567 result = ctx.run(command, hide=True, warn=True) 

2568 ok = bool(result and result.ok) 

2569 should_print = not ok and mode != "json" 

2570 

2571 if should_print and result is not None and result.stdout: 

2572 print(result.stdout, end="") 

2573 

2574 if should_print and result is not None and result.stderr: 

2575 print(result.stderr, end="", file=sys.stderr) 

2576 

2577 if tool and mode in {"cli", "ci"}: 

2578 _print_lint_status(tool, ok, mode) 

2579 

2580 return ok 

2581 

2582 

2583@task( 

2584 hookable=True, 

2585 help={"output": "output mode: cli, ci, or json"}, 

2586 result_renderer=_render_lint_result, 

2587) 

2588def lint( 

2589 ctx: Context, 

2590 directory: Optional[str] = None, 

2591 select: str = "", 

2592 fix: bool = False, 

2593 output: OutputMode = "cli", 

2594) -> LintOutput: 

2595 """ 

2596 Lint code with `ruff` and `ty`. 

2597 

2598 Disable either tool for a project with ``[tool.edwh.lint]`` in 

2599 ``pyproject.toml``. Both are enabled by default. 

2600 

2601 Args: 

2602 ctx: invoke context 

2603 directory: where to look for code 

2604 select: specific lints to check 

2605 fix: try to fix (some) issues automatically 

2606 output: render output as cli, ci, or json 

2607 """ 

2608 directory = directory or "." 

2609 output = t.cast(OutputMode, output.lower()) 

2610 

2611 if output not in {"cli", "ci", "json"}: 

2612 raise ValueError(f"Invalid --output value: {output}. Expected one of: cli, ci, json.") 

2613 

2614 enabled_tools = enabled_lint_tools() 

2615 results: dict[str, bool] = {} 

2616 

2617 if enabled_tools["ruff"]: 

2618 ruff = find_ruff() 

2619 command = [ruff, "check", directory, "--quiet"] 

2620 if select: 

2621 command.extend(("--select", select)) 

2622 if fix: 

2623 command.append("--fix") 

2624 

2625 results["linting (ruff)"] = run_command_with_output(ctx, shlex.join(command), mode=output, tool="ruff") 

2626 

2627 if enabled_tools["ty"]: 

2628 ty = find_ty() 

2629 results["type checking (ty)"] = run_command_with_output( 

2630 ctx, 

2631 shlex.join([ty, "check", directory]), 

2632 mode=output, 

2633 tool="ty", 

2634 ) 

2635 

2636 return {"mode": output, "results": results} 

2637 

2638 

2639@task(aliases=("format",), hookable=True) 

2640def fmt( 

2641 ctx: Context, 

2642 isort: bool = True, 

2643 ioptimize: bool = False, 

2644 reformat: bool = True, 

2645 directory: Optional[str] = None, 

2646 file: Optional[str] = None, 

2647 quiet: bool = False, 

2648): 

2649 """ 

2650 Format your Python code with `ruff`, including import sorting (isort). 

2651 

2652 `ioptimize` would remove unused imports, but that functionality in `ruff` doesn't seem to work right now 

2653 -> so only display problems for now. 

2654 

2655 `file` and `directory` have the same behavior, the different names are there for sugar. 

2656 """ 

2657 if file and directory: 

2658 raise ValueError("Conflicting arguments --file and --directory. Please pick one, the behavior is the same.") 

2659 

2660 target = directory or file or "." 

2661 

2662 ruff = find_ruff() 

2663 

2664 color: Color 

2665 

2666 if isort: 

2667 color = "green" if run_pty_ok(ctx, ruff, f"check --select I --fix {target} --quiet") else "red" 

2668 cprint("⬤ isort", color=color) 

2669 

2670 if reformat: 

2671 # note: ruff format --quiet also hides what's wrong, so instead pipe stdout to dev null and only show stderr: 

2672 color = "green" if run_pty_ok(ctx, ruff, f"format {target} > /dev/null") else "red" 

2673 cprint("⬤ reformat", color=color) 

2674 

2675 if not quiet and not ioptimize: 

2676 # print out unused imports: 

2677 try: 

2678 # grep remove the --fix suggestion since we have other cli args; 

2679 # grep piping removes the nice coloring unless we force it; 

2680 # pipefall would forward ruff's exit code but if grep has no output, it exits with 1. 

2681 # so we do this fuckery: 

2682 ctx.run( 

2683 f""" 

2684 ruff_output=$(FORCE_COLOR=1 {ruff} check --select F401 {target}) 

2685 ruff_exit=$? 

2686 echo "$ruff_output" | grep -v -E '(`--fix`|^All checks passed!$)' || true 

2687 exit $ruff_exit 

2688 """, 

2689 pty=True, 

2690 ) 

2691 except invoke.exceptions.UnexpectedExit: 

2692 cprint( 

2693 "Hint: unused imports can be removed with --ioptimize; this check can be skipped with --quiet", 

2694 "blue", 

2695 ) 

2696 

2697 elif ioptimize: 

2698 # else, autofix F401 = unused-import 

2699 color = "green" if run_pty_ok(ctx, ruff, f"check --select F401 {target} --fix --quiet") else "red" 

2700 cprint("⬤ ioptimize", color=color)