• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mborsetti / webchanges / 24977503816

27 Apr 2026 05:04AM UTC coverage: 73.256% (+0.01%) from 73.246%
24977503816

push

github

mborsetti
Version 3.36.1rc1

Co-authored-by: Copilot <copilot@github.com>

1538 of 2498 branches covered (61.57%)

Branch coverage included in aggregate %.

155 of 197 new or added lines in 4 files covered. (78.68%)

5340 of 6891 relevant lines covered (77.49%)

10.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.95
/webchanges/cli.py
1
#!/usr/bin/env python3
2

3
"""Module containing the entry point: the function main()."""
4

5
# See config module for the command line arguments.
6

7
# The code below is subject to the license contained in the LICENSE.md file, which is part of the source code.
8

9
from __future__ import annotations
14✔
10

11
import logging
14✔
12
import os
14✔
13
import platform
14✔
14
import shutil
14✔
15
import signal
14✔
16
import subprocess
14✔
17
import sys
14✔
18
import warnings
14✔
19
from pathlib import Path, PurePath
14✔
20

21
import platformdirs
14✔
22

23
from webchanges import __copyright__, __docs_url__, __min_python_version__, __project_name__, __version__
14✔
24
from webchanges.config import CommandConfig
14✔
25
from webchanges.util import edit_file, file_ownership_checks, get_new_version_number, import_module_from_source
14✔
26

27
# Restore the default system behavior for the SIGPIPE signal, which is ignored by Python by default.
28
# This prevents a BrokenPipeError when piping output to a command like `less` that may close the pipe before reading all
29
# of the output.
30
try:
14✔
31
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)  # not defined in Windows  # ty:ignore[unresolved-attribute]
14✔
32
except AttributeError:
5✔
33
    pass
5✔
34

35

36
logger = logging.getLogger(__name__)
14✔
37

38

39
def python_version_warning() -> None:
14✔
40
    """Check if we're running on the minimum Python version supported and if so print and issue a pending deprecation
41
    warning.
42
    """
43
    if sys.version_info[0:2] == __min_python_version__:
14✔
44
        current_minor_version = '.'.join(str(n) for n in sys.version_info[0:2])
3✔
45
        next_minor_version = f'{__min_python_version__[0]}.{__min_python_version__[1] + 1}'
3✔
46
        warning = (
3✔
47
            f'Support for Python {current_minor_version} will be ending three years from the date Python '
48
            f'{next_minor_version} was released'
49
        )
50
        print(f'WARNING: {warning}\n')
3✔
51
        PendingDeprecationWarning(warning)
3✔
52

53

54
def migrate_from_legacy(
14✔
55
    legacy_package: str,
56
    config_file: Path | None = None,
57
    jobs_file: Path | None = None,
58
    hooks_file: Path | None = None,
59
    ssdb_file: Path | None = None,
60
) -> None:
61
    """Check for existence of legacy files for configuration, jobs and Python hooks and migrate them (i.e. make a copy
62
    to new folder and/or name). Original files are not deleted.
63

64
    :param legacy_package: The name of the legacy package to migrate (e.g. urlwatch).
65
    :param config_file: The new Path to the configuration file.
66
    :param jobs_file: The new Path to the jobs file.
67
    :param hooks_file: The new Path to the hooks file.
68
    :param ssdb_file: The new Path to the snapshot database file.
69
    """
70
    legacy_project_path = Path.home().joinpath(f'.{legacy_package}')
14✔
71
    leagacy_config_file = legacy_project_path.joinpath(f'{legacy_package}.yaml')
14✔
72
    legacy_urls_file = legacy_project_path.joinpath('urls.yaml')
14✔
73
    legacy_hooks_file = legacy_project_path.joinpath('hooks.py')
14✔
74
    legacy_cache_path = platformdirs.user_cache_path(legacy_package)
14✔
75
    legacy_cache_file = legacy_cache_path.joinpath('cache.db')
14✔
76
    for old_file, new_file in zip(
14✔
77
        (leagacy_config_file, legacy_urls_file, legacy_hooks_file, legacy_cache_file),
78
        (config_file, jobs_file, hooks_file, ssdb_file),
79
        strict=False,
80
    ):
81
        if new_file and old_file.is_file() and not new_file.is_file():
14✔
82
            new_file.parent.mkdir(parents=True, exist_ok=True)
14✔
83
            shutil.copyfile(old_file, new_file)
14✔
84
            logger.warning(f"Copied {legacy_package} '{old_file}' file to {__project_name__} '{new_file}'.")
14✔
85
            logger.warning(f"You can safely delete '{old_file}'.")
14✔
86

87

88
def setup_logger(verbose: int | None = None, log_file: Path | None = None) -> None:
14✔
89
    """Set up the logger.
90

91
    :param verbose: the verbosity level (1 = INFO, 2 = ERROR, 3 = NOTSET).
92
    """
93
    if log_file:
14✔
94
        handlers: tuple[logging.Handler, ...] | None = (logging.FileHandler(log_file),)
14✔
95
        if not verbose:
14!
96
            verbose = 1
14✔
97
    else:
98
        handlers = None
14✔
99

100
    log_level = None
14✔
101

102
    if verbose is not None:
14!
103
        if verbose >= 3:
14✔
104
            log_level = 'NOTSET'
14✔
105
            # https://playwright.dev/python/docs/debug#verbose-api-logs
106
            os.environ['DEBUG'] = 'pw:api pytest -s'
14✔
107
        elif verbose >= 2:
14✔
108
            log_level = 'DEBUG'
14✔
109
            # https://playwright.dev/python/docs/debug#verbose-api-logs
110
            os.environ['DEBUG'] = 'pw:api pytest -s'
14✔
111
        elif verbose == 1:
14!
112
            log_level = 'INFO'
14✔
113

114
    # if not verbose:
115
    #     sys.tracebacklimit = 0
116

117
    logging.basicConfig(
14✔
118
        format='%(asctime)s %(module)s[%(thread)s] %(levelname)s: %(message)s',
119
        level=log_level,
120
        handlers=handlers,
121
    )
122
    logger.info(f'{__project_name__}: {__version__} {__copyright__}')
14✔
123
    logger.info(
14✔
124
        f'{platform.python_implementation()}: {platform.python_version()} '
125
        f'{platform.python_build()} {platform.python_compiler()}'
126
    )
127
    logger.info(f'System: {platform.platform()}')
14✔
128

129

130
def teardown_logger(verbose: int | None = None) -> None:
14✔
131
    """Clean up logging.
132

133
    :param verbose: the verbosity level (1 = INFO, 2 = ERROR).
134
    """
135
    if verbose is not None and verbose >= 2:
14✔
136
        # https://playwright.dev/python/docs/debug#verbose-api-logs
137
        os.environ.pop('DEBUG', None)
14✔
138

139

140
def _expand_glob_files(
14✔
141
    filename: Path,
142
    default_path: Path,
143
    ext: str | None = None,
144
    prefix: str | None = None,
145
) -> list[Path]:
146
    """Searches for file both as specified and in the default directory, then retries with 'ext' extension if defined.
147

148
    :param filename: The filename.
149
    :param default_path: The default directory.
150
    :param ext: The extension, e.g. '.yaml', to add for searching if first scan fails.
151
    :param prefix: The prefix, e.g. 'config', to add with a hypen (e.g. 'config-') for searching if first scan fails.
152

153
    :returns: The filename, either original or one with path where found and/or extension.
154
    """
155
    search_filenames = [filename]
14✔
156

157
    # if ext is given, iterate both on raw filename and the filename with ext if different
158
    if ext and filename.suffix != ext:
14✔
159
        search_filenames.append(filename.with_suffix(ext))
14✔
160

161
    # if prefix is given, iterate both on raw filename and the filename with prefix if different
162
    if prefix and not filename.name.startswith(prefix):
14✔
163
        search_filenames.append(filename.with_stem(f'{prefix}-{filename.stem}'))
14✔
164
        if ext and filename.suffix != ext:
14✔
165
            search_filenames.append(filename.with_stem(f'{prefix}-{filename.stem}').with_suffix(ext))
14✔
166

167
    # try as given
168
    for file in search_filenames:
14✔
169
        # https://stackoverflow.com/questions/56311703/globbing-absolute-paths-with-pathlib
170
        file_list = list(Path(file.anchor).glob(str(file.relative_to(file.anchor))))
14✔
171
        if any(f.is_file() for f in file_list):
14✔
172
            return file_list
14✔
173

174
        # no directory specified (and not in current one): add default one
175
        if not file.is_absolute() and Path(file).parent != Path.cwd():
14✔
176
            file_list = list(default_path.glob(str(file)))
14✔
177
            if any(f.is_file() for f in file_list):
14!
178
                return file_list
14✔
179

180
    # no matches found
181
    return [filename]
14✔
182

183

184
def locate_glob_files(
14✔
185
    filenames: list[Path],
186
    default_path: Path,
187
    ext: str | None = None,
188
    prefix: str | None = None,
189
) -> list[Path]:
190
    job_files = set()
14✔
191
    for filename in filenames:
14✔
192
        for file in _expand_glob_files(filename, default_path, ext, prefix):
14✔
193
            job_files.add(file)
14✔
194
    return list(job_files)
14✔
195

196

197
def locate_storage_file(
14✔
198
    filename: Path,
199
    default_path: Path,
200
    ext: str | None = None,
201
    prefix: str | None = None,
202
) -> Path:
203
    """Searches for file both as specified and in the default directory, then retries with 'ext' extension if defined.
204

205
    :param filename: The filename.
206
    :param default_path: The default directory.
207
    :param ext: The extension, e.g. '.yaml', to add for searching if first scan fails.
208
    :param prefix: The prefix, e.g. 'config', to add with a hypen (e.g. 'config-') for searching if first scan fails.
209

210
    :returns: The filename, either original or one with path where found and/or extension.
211
    """
212
    search_filenames = [filename]
14✔
213

214
    # if ext is given, iterate both on raw filename and the filename with ext if different
215
    if ext and filename.suffix != ext:
14✔
216
        search_filenames.append(filename.with_suffix(ext))
14✔
217

218
    # if prefix is given, iterate both on raw filename and the filename with prefix if different
219
    if prefix and not filename.name.startswith(prefix):
14✔
220
        search_filenames.append(filename.with_stem(f'{prefix}-{filename.stem}'))
14✔
221
        if ext and filename.suffix != ext:
14!
222
            search_filenames.append(filename.with_stem(f'{prefix}-{filename.stem}').with_suffix(ext))
14✔
223

224
    for file in search_filenames:
14!
225
        # return if found
226
        if file.is_file():
14✔
227
            return file
14✔
228

229
        # no directory specified (and not in current one): add default one
230
        if file.parent == PurePath():
14!
231
            new_file = default_path.joinpath(file)
14✔
232
            if new_file.is_file():
14✔
233
                return new_file
14✔
234

235
    # no matches found
236
    return filename
×
237

238

239
def locate_storage_files(
14✔
240
    filename_list: list[Path],
241
    default_path: Path,
242
    ext: str | None = None,
243
    prefix: str | None = None,
244
) -> set[Path]:
245
    """Searches for file both as specified and in the default directory, then retries with 'ext' extension if defined.
246

247
    :param filename_list: The list of filenames.
248
    :param default_path: The default directory.
249
    :param ext: The extension, e.g. '.yaml', to add for searching if first scan fails.
250
    :param prefix: The prefix, e.g. 'config', to add with a hypen (e.g. 'config-') for searching if first scan fails.
251

252
    :returns: The list filenames, either originals or ones with path where found and/or extension.
253
    """
254
    filenames = set()
14✔
255
    for filename in filename_list:
14✔
256
        filenames.add(locate_storage_file(filename, default_path, ext, prefix))
14✔
257
    return filenames
14✔
258

259

260
def first_run(command_config: CommandConfig) -> None:
14✔
261
    """Create configuration and jobs files.
262

263
    :param command_config: the CommandConfig containing the command line arguments selected.
264
    """
265
    if not command_config.config_file.is_file():
14!
266
        command_config.config_file.parent.mkdir(parents=True, exist_ok=True)
14✔
267
        from webchanges.storage import YamlConfigStorage
14✔
268

269
        YamlConfigStorage.write_default_config(command_config.config_file)
14✔
270
        print(f'Created default config file at {command_config.config_file}')
14✔
271
        if not command_config.edit_config:
14!
272
            print(f'> Edit it with {__project_name__} --edit-config')
14✔
273
    if not any(f.is_file() for f in command_config.jobs_files):
14!
274
        command_config.jobs_files[0].parent.mkdir(parents=True, exist_ok=True)
14✔
275
        command_config.jobs_files[0].write_text(
14✔
276
            f'# {__project_name__} jobs file. See {__docs_url__}en/stable/jobs.html\n'
277
        )
278
        print(f'Created default jobs file at {command_config.jobs_files[0]}')
14✔
279
        if not command_config.edit:
14!
280
            print(f'> Edit it with {__project_name__} --edit-jobs')
14✔
281

282

283
def load_hooks(hooks_file: Path, is_default: bool = False) -> None:
14✔
284
    """Load hooks file."""
285
    if not hooks_file.is_file():
14✔
286
        if is_default:
14✔
287
            logger.info(f'Hooks file {hooks_file} does not exist or is not a file')
14✔
288
        else:
289
            # do not use ImportWarning as it could be suppressed
290
            warnings.warn(
14✔
291
                f'Hooks file {hooks_file} not imported because it does not exist or is not a file',
292
                RuntimeWarning,
293
                stacklevel=1,
294
            )
295
        return
14✔
296

297
    hooks_file_errors = file_ownership_checks(hooks_file)
14✔
298
    if hooks_file_errors:
14✔
299
        logger.debug('Here should come the warning')
14✔
300
        # do not use ImportWarning as it could be suppressed
301
        warnings.warn(
14✔
302
            f'Hooks file {hooks_file} not not imported because{" and ".join(hooks_file_errors)}.\n'
303
            f'(see {__docs_url__}en/stable/hooks.html#important-note-for-hooks-file)',
304
            RuntimeWarning,
305
            stacklevel=1,
306
        )
307
    else:
308
        logger.info(f'Importing into hooks module from {hooks_file}')
14✔
309
        import_module_from_source('hooks', hooks_file)
14✔
310
        logger.info('Finished importing into hooks module')
14✔
311

312

313
def sync_bundled_schemas(command_config: CommandConfig) -> None:
14✔
314
    """Deploy bundled JSON schemas next to the user's ``config.yaml`` and ``jobs.yaml``.
315

316
    ``config.schema.json`` is written next to the ``--config`` file; ``jobs.schema.json`` next to the first
317
    ``--jobs`` file. A sibling ``.*.sha256`` file records the deployed hash so subsequent runs can detect when the
318
    bundled schema has changed without re-hashing on every invocation.
319
    """
320
    from importlib.resources import files as resource_files
14✔
321

322
    try:
14✔
323
        schema_root = resource_files('webchanges._resources')
14✔
324
    except (ModuleNotFoundError, FileNotFoundError):
14✔
325
        logger.debug('Bundled schemas package not found; skipping schema sync.')
14✔
326
        return
14✔
327

328
    targets = [('config.schema', command_config.config_file.parent)]
14✔
329
    if command_config.jobs_files:
14✔
330
        targets.append(('jobs.schema', command_config.jobs_files[0].parent))
14✔
331

332
    for stem, target_dir in targets:
14✔
333
        bundled_hash_resource = schema_root / f'.{stem}.sha256'
14✔
334
        try:
14✔
335
            bundled_hash = bundled_hash_resource.read_text(encoding='utf-8').strip()
14✔
336
        except (FileNotFoundError, OSError):
×
337
            logger.debug(f'Bundled hash .{stem}.sha256 not available; skipping.')
×
338
            continue
×
339
        if not bundled_hash:
14!
340
            logger.debug(f'Bundled hash .{stem}.sha256 is empty; skipping.')
×
341
            continue
×
342

343
        deployed_hash_file = target_dir / f'.{stem}.sha256'
14✔
344
        try:
14✔
345
            deployed_hash = deployed_hash_file.read_text(encoding='utf-8').strip() or None
14✔
346
        except (FileNotFoundError, OSError):
14✔
347
            deployed_hash = None
14✔
348
        if deployed_hash == bundled_hash:
14✔
349
            continue
14✔
350

351
        try:
14✔
352
            target_dir.mkdir(parents=True, exist_ok=True)
14✔
353
            schema_dest = target_dir / f'{stem}.json'
14✔
354
            tmp_schema = schema_dest.parent / (schema_dest.name + '.tmp')
14✔
355
            tmp_schema.write_bytes((schema_root / f'{stem}.json').read_bytes())
14✔
356
            tmp_schema.replace(schema_dest)
14✔
357
            tmp_hash = deployed_hash_file.parent / (deployed_hash_file.name + '.tmp')
14✔
358
            tmp_hash.write_text(f'{bundled_hash}\n', encoding='utf-8')
14✔
359
            tmp_hash.replace(deployed_hash_file)
14✔
360
        except OSError as e:
×
361
            logger.info(f'Could not deploy {stem}.json to {target_dir}: {e}')
×
362
            continue
×
363
        logger.info(f'Updated {schema_dest} (sha256 {bundled_hash[:12]}…).')
14✔
364

365

366
def handle_unitialized_actions(urlwatch_config: CommandConfig, default_config_file: Path | None = None) -> None:
14✔
367
    """Handles CLI actions that do not require all classes etc. to be initialized (and command.py loaded). For speed
368
    purposes.
369

370
    The editor commands (``--edit-jobs``, ``--edit-config``, ``--edit-hooks``) are dispatched here too so that a
371
    malformed user file does not block the user from opening it. ``default_config_file`` is the platform default so
372
    we can mirror ``main()``'s ``first_run`` gate.
373
    """
374

375
    def _exit(arg: str | int | None) -> None:
14✔
376
        logger.info(f'Exiting with exit code {arg}')
14✔
377
        sys.exit(arg)
14✔
378

379
    def print_new_version() -> int:
14✔
380
        """Will print alert message if a newer version is found on PyPi."""
381
        print(f'{__project_name__} {__version__}.', end='')
14✔
382
        new_release = get_new_version_number(timeout=2)
14✔
383
        if new_release:
14✔
384
            print(
14✔
385
                f'\nNew release version {new_release} is available; we recommend updating using e.g. '
386
                f"'pip install -U {__project_name__}'."
387
            )
388
            return 0
14✔
389
        if new_release == '':
14✔
390
            print(' You are running the latest release.')
14✔
391
            return 0
14✔
392
        print(' Error contacting PyPI to determine the latest release.')
14✔
393
        return 1
14✔
394

395
    def playwright_install_chrome() -> int:  # pragma: no cover
396
        """Replicates playwright.___main__.main() function, which is called by the playwright executable, in order to
397
        install the browser executable.
398

399
        :return: Playwright's executable return code.
400
        """
401
        try:
402
            from playwright._impl._driver import compute_driver_executable
403
        except ImportError:  # pragma: no cover
404
            raise ImportError('Python package playwright is not installed; cannot install the Chrome browser') from None
405

406
        driver_executable = compute_driver_executable()
407
        env = os.environ.copy()
408
        env['PW_CLI_TARGET_LANG'] = 'python'
409
        cmd = [str(driver_executable), 'install', 'chrome']
410
        logger.info(f'Running playwright CLI: {" ".join(cmd)}')
411
        completed_process = subprocess.run(cmd, check=False, env=env, capture_output=True, text=True)  # noqa: S603
412
        if completed_process.returncode:
413
            print(completed_process.stderr)
414
            return completed_process.returncode
415
        if completed_process.stdout:
416
            logger.info(f'Success! Output of Playwright CLI: {completed_process.stdout}')
417
        return 0
418

419
    if urlwatch_config.check_new:
14✔
420
        _exit(print_new_version())
14✔
421

422
    if urlwatch_config.install_chrome:  # pragma: no cover
423
        _exit(playwright_install_chrome())
424

425
    if urlwatch_config.detailed_versions:
14✔
426
        _exit(show_detailed_versions())
14✔
427

428
    if urlwatch_config.edit or urlwatch_config.edit_config or urlwatch_config.edit_hooks:
14✔
429
        # Resolve paths and run the same first-run / schema-sync setup that main() does, so editing works
430
        # identically regardless of which dispatch path runs the command.
431
        urlwatch_config.config_file = locate_storage_file(
14✔
432
            filename=urlwatch_config.config_file,
433
            default_path=urlwatch_config.config_path,
434
            ext='.yaml',
435
            prefix='config',
436
        )
437
        urlwatch_config.jobs_files = locate_glob_files(
14✔
438
            filenames=urlwatch_config.jobs_files,
439
            default_path=urlwatch_config.config_path,
440
            ext='.yaml',
441
            prefix='jobs',
442
        )
443
        urlwatch_config.hooks_files = locate_glob_files(
14✔
444
            filenames=urlwatch_config.hooks_files,
445
            default_path=urlwatch_config.config_path,
446
            ext='.py',
447
            prefix='hooks',
448
        )
449
        sync_bundled_schemas(urlwatch_config)
14✔
450
        if (
14!
451
            default_config_file is not None
452
            and urlwatch_config.config_file == default_config_file
453
            and not Path(urlwatch_config.config_file).is_file()
454
        ):
NEW
455
            first_run(urlwatch_config)
×
456
        if urlwatch_config.edit_hooks:
14✔
457
            _exit(_edit_hooks_files(urlwatch_config.hooks_files))
14✔
458
        if urlwatch_config.edit_config:
14✔
459
            _exit(_edit_config_file(urlwatch_config.config_file))
14✔
460
        if urlwatch_config.edit:
14!
461
            _exit(_edit_jobs_files(urlwatch_config.jobs_files))
14✔
462

463

464
def _edit_jobs_files(jobs_files: list[Path]) -> int:
14✔
465
    """Open the jobs file in the user's editor and validate it on save."""
466
    from webchanges.storage import YamlJobsStorage
14✔
467

468
    return YamlJobsStorage(jobs_files).edit()
14✔
469

470

471
def _edit_config_file(config_file: Path) -> int:
14✔
472
    """Open the configuration file in the user's editor and validate it on save."""
473
    from webchanges.storage import YamlConfigStorage
14✔
474

475
    return YamlConfigStorage(config_file).edit()
14✔
476

477

478
def _edit_hooks_files(hooks_files: list[Path]) -> int:
14✔
479
    """Open each hooks file in the user's editor and validate it on save."""
480
    # Mirrors the original logic from UrlwatchCommand.edit_hooks.
481
    for hooks_file in hooks_files:
14✔
482
        logger.debug(f'Edit file {hooks_file}')
14✔
483
        hooks_edit = hooks_file.with_stem(hooks_file.stem + '_edit')
14✔
484
        if hooks_file.exists():
14!
485
            shutil.copy(hooks_file, hooks_edit)
14✔
486

487
        while True:
14✔
488
            try:
14✔
489
                edit_file(hooks_edit)
14✔
490
                import_module_from_source('hooks', hooks_edit)
14✔
491
                break
14✔
492
            except SystemExit:
14✔
NEW
493
                raise
×
494
            except Exception as e:  # noqa: BLE001 Do not catch blind exception: `Exception`
14✔
495
                print('Parsing failed:')
14✔
496
                print('======')
14✔
497
                print(e)
14✔
498
                print('======')
14✔
499
                print()
14✔
500
                print(f'The file {hooks_file} was NOT updated.')
14✔
501
                user_input = input('Do you want to retry the same edit? (Y/n)')
14✔
NEW
502
                if not user_input or user_input.lower()[0] == 'y':
×
NEW
503
                    continue
×
NEW
504
                hooks_edit.unlink()
×
NEW
505
                print('No changes have been saved.')
×
NEW
506
                return 1
×
507

508
        if hooks_file.is_symlink():
14!
NEW
509
            hooks_file.write_text(hooks_edit.read_text())
×
510
        else:
511
            hooks_edit.replace(hooks_file)
14✔
512
        hooks_edit.unlink(missing_ok=True)
14✔
513
        print(f'Saved edits in {hooks_file}.')
14✔
514

515
    return 0
14✔
516

517

518
def _print_dist_sub_deps(name: str, raw: list[str]) -> None:
14✔
519
    """Print sub-dependencies of distribution ``name`` from its PEP 508 requirement strings ``raw``.
520

521
    Uses ``packaging.requirements.Requirement`` to honour markers (``python_version``, ``sys_platform``, ``extra``, ...)
522
    when available. Falls back to a regex that strips version specifiers but cannot evaluate markers.
523
    """
524
    import importlib.metadata
14✔
525
    import re
14✔
526

527
    try:
14✔
528
        from packaging.requirements import InvalidRequirement, Requirement
14✔
529
    except ImportError:
530
        # Fallback: requirements without whitespace before the operator (e.g. ``httpx>=0.20``) would otherwise be
531
        # treated as invalid distribution names; this regex strips the specifier so the lookup succeeds.
532
        for req_name in sorted({re.split('[ <>=;#^[]', i)[0] for i in raw}):
533
            try:
534
                installed = importlib.metadata.distribution(req_name)
535
            except ModuleNotFoundError:
536
                continue
537
            print(f'  - {req_name}: {installed.version}')
538
        return
539

540
    # Try every extra the package declares so that, e.g., an ``extra == "crypto"`` dep shows up if the user installed
541
    # ``package[crypto]``.
542
    try:
14✔
543
        extras = set(importlib.metadata.metadata(name).get_all('Provides-Extra') or [])
14✔
NEW
544
    except importlib.metadata.PackageNotFoundError:
×
NEW
545
        extras = set()
×
546
    envs = [{'extra': e} for e in ('', *extras)]
14✔
547
    seen: set[str] = set()
14✔
548
    for line in sorted(raw):
14✔
549
        try:
14✔
550
            req = Requirement(line)
14✔
NEW
551
        except InvalidRequirement:
×
NEW
552
            continue
×
553
        if req.marker is not None and not any(req.marker.evaluate(env) for env in envs):
14✔
554
            continue
14✔
555
        if req.name in seen:
14✔
556
            continue
14✔
557
        seen.add(req.name)
14✔
558
        try:
14✔
559
            installed = importlib.metadata.distribution(req.name)
14✔
560
        except ModuleNotFoundError:
14✔
561
            continue
14✔
562
        print(f'  - {req.name}: {installed.version}')
14✔
563

564

565
def show_detailed_versions() -> int:
14✔
566
    """Prints the detailed versions, including of dependencies.
567

568
    :return: 0.
569
    """
570
    import importlib.metadata
14✔
571
    import re
14✔
572
    import sqlite3
14✔
573
    from concurrent.futures import ThreadPoolExecutor
14✔
574

575
    def dependencies() -> list[str]:
14✔
576
        try:
14✔
577
            from pip._internal.metadata import get_default_environment
14✔
578

579
            env = get_default_environment()
14✔
580
            dist = None
14✔
581
            for dist in env.iter_all_distributions():
14✔
582
                if dist.canonical_name == __project_name__:
14!
NEW
583
                    break
×
584
            if dist and dist.canonical_name == __project_name__:
14!
NEW
585
                requires_dist = dist.metadata_dict.get('requires_dist', [])
×
NEW
586
                dependencies = {re.split('[ <>=;#^[]', d)[0] for d in requires_dist}
×
NEW
587
                dependencies.update(('httpx', 'packaging', 'simplejson', 'typeguard'))
×
NEW
588
                return sorted(dependencies, key=str.lower)
×
589
        except ImportError:
590
            pass
591

592
        # default list of all possible dependencies
593
        logger.info(f'Found no pip distribution for {__project_name__}; returning all possible dependencies.')
14✔
594
        deps = [
14✔
595
            'aioxmpp',
596
            'beautifulsoup4',
597
            'chump',
598
            'colorama',
599
            'cryptography',
600
            'cssbeautifier',
601
            'cssselect',
602
            'curl_cffi',
603
            'deepdiff',
604
            'h2',
605
            'html2text',
606
            'html5lib',
607
            'httpx',
608
            'jq',
609
            'jsbeautifier',
610
            'keyring',
611
            'lxml',
612
            'markdown2',
613
            'matrix_client',
614
            'msgpack',
615
            'packaging',
616
            'pdftotext',
617
            'Pillow',
618
            'platformdirs',
619
            'playwright',
620
            'psutil',
621
            'pushbullet.py',
622
            'pypdf',
623
            'pytesseract',
624
            'pyyaml',
625
            'redis',
626
            'requests',
627
            'simplejson',
628
            'typeguard',
629
            'typing-extensions',
630
            'tzdata',
631
            'vobject',
632
            'xmltodict',
633
        ]
634
        if sys.version_info < (3, 14):
14✔
635
            deps.append('zstandard')
9✔
636
        return sorted(deps)
14✔
637

638
    print('Software:')
14✔
639
    print(f'• {__project_name__}: {__version__}')
14✔
640
    print(
14✔
641
        f'• {platform.python_implementation()}: {platform.python_version()} '
642
        f'{platform.python_build()} {platform.python_compiler()}'
643
    )
644
    print(f'• SQLite: {sqlite3.sqlite_version}')
14✔
645

646
    try:
14✔
647
        import psutil
14✔
648
        from psutil._common import bytes2human
14✔
649

650
        print()
14✔
651
        print('System:')
14✔
652
        print(f'• Platform: {platform.platform()}, {platform.machine()}')
14✔
653
        print(f'• Processor: {platform.processor()}')
14✔
654
        print(f'• CPUs (logical): {psutil.cpu_count()}')
14✔
655
        try:
14✔
656
            virt_mem = psutil.virtual_memory().available
14✔
657
            print(
14✔
658
                f'• Free memory: {bytes2human(virt_mem)} physical plus {bytes2human(psutil.swap_memory().free)} swap.'
659
            )
660
        except psutil.Error as e:  # pragma: no cover
661
            print(f'• Free memory: Could not read information: {e}')
662
        print(
14✔
663
            f"• Free disk '/': {bytes2human(psutil.disk_usage('/').free)} ({100 - psutil.disk_usage('/').percent:.1f}%)"
664
        )
665
        executor = ThreadPoolExecutor()
14✔
666
        print(f'• --max-threads default value: {executor._max_workers}')
14✔
667
    except ImportError:
668
        pass
669

670
    print()
14✔
671
    print('Relevant PyPi packages:')
14✔
672
    for dist_name in dependencies():
14✔
673
        if dist_name == __project_name__:
14!
NEW
674
            continue
×
675
        try:
14✔
676
            mod = importlib.metadata.distribution(dist_name)
14✔
677
        except ModuleNotFoundError:
14✔
678
            continue
14✔
679
        print(f'• {dist_name}: {mod.version}')
14✔
680
        if mod.requires:
14✔
681
            _print_dist_sub_deps(dist_name, mod.requires)
14✔
682

683
    # playwright
684
    try:
14✔
685
        from playwright.sync_api import Error as PlaywrightError
14✔
686
        from playwright.sync_api import sync_playwright
11✔
687

688
        if psutil:
11!
689
            try:
11✔
690
                virt_mem_before = psutil.virtual_memory().available
11✔
691
                swap_mem_before = psutil.swap_memory().free
11✔
NEW
692
            except psutil.Error:
×
NEW
693
                pass
×
694

695
        with sync_playwright() as p:
11✔
696
            try:
2✔
697
                print()
2✔
698
                print('Playwright browser:')
2✔
699
                browser = p.chromium.launch(channel='chrome')
2✔
700
                print(f'• Name: {browser.browser_type.name}')
2✔
701
                print(f'• Version: {browser.version}')
2✔
702
                if psutil:
2!
703
                    try:
2✔
704
                        print(
2✔
705
                            f'• Free memory before launching browser: '
706
                            f'{bytes2human(virt_mem_before)} physical plus '
707
                            f'{bytes2human(swap_mem_before)} swap'
708
                        )
NEW
709
                    except NameError:
×
NEW
710
                        pass
×
711
                if psutil:
2!
712
                    browser.new_page()
2✔
713
                    try:
2✔
714
                        virt_mem_loaded = psutil.virtual_memory().available
2✔
715
                        swap_mem_loaded = psutil.swap_memory().free
2✔
716
                        print(
2✔
717
                            f'• Free memory with browser running    : '
718
                            f'{bytes2human(virt_mem_loaded)} physical plus '
719
                            f'{bytes2human(swap_mem_loaded)} swap'
720
                        )
NEW
721
                    except psutil.Error:
×
NEW
722
                        pass
×
723
                    try:
2✔
724
                        virt_mem = virt_mem_before - virt_mem_loaded
2✔
725
                        swap_mem = swap_mem_before - swap_mem_loaded
2✔
726
                        print(
2✔
727
                            f'• Memory used by launching browser    : '
728
                            f'{bytes2human(virt_mem)} physical plus '
729
                            f'{bytes2human(swap_mem)} swap'
730
                        )
NEW
731
                    except NameError:
×
NEW
732
                        pass
×
733
                print(f'• Executable: {browser.browser_type.executable_path}')
2✔
NEW
734
            except PlaywrightError as e:
×
NEW
735
                print()
×
NEW
736
                print('Playwright browser:')
×
NEW
737
                print(f'• Error: {e}')
×
738
    except ImportError:
739
        pass
740

741
    if os.name == 'posix':
14✔
742
        print()
9✔
743
        print('Installed dpkg dependencies:')
9✔
744
        try:
9✔
745
            import apt
9✔
746

NEW
747
            apt_cache = apt.Cache()
×
748

NEW
749
            def print_version(libs: list[str]) -> None:
×
NEW
750
                for lib in libs:
×
NEW
751
                    if lib in apt_cache and apt_cache[lib].versions:
×
NEW
752
                        ver = apt_cache[lib].versions
×
NEW
753
                        print(f'   - {ver[0].package}: {ver[0].version}')
×
754

NEW
755
            installed_packages = {dist.metadata['Name'] for dist in importlib.metadata.distributions()}
×
NEW
756
            for module, apt_dists in (
×
757
                ('jq', ['jq']),
758
                # https://github.com/jalan/pdftotext#os-dependencies
759
                ('pdftotext', ['libpoppler-cpp-dev']),
760
                # https://pillow.readthedocs.io/en/latest/installation.html#external-libraries
761
                (
762
                    'Pillow',
763
                    [
764
                        'libjpeg-dev',
765
                        'zlib-dev',
766
                        'zlib1g-dev',
767
                        'libtiff-dev',
768
                        'libfreetype-dev',
769
                        'littlecms-dev',
770
                        'libwebp-dev',
771
                        'tcl/tk-dev',
772
                        'openjpeg-dev',
773
                        'libimagequant-dev',
774
                        'libraqm-dev',
775
                        'libxcb-dev',
776
                        'libxcb1-dev',
777
                    ],
778
                ),
779
                ('playwright', ['google-chrome-stable']),
780
                # https://tesseract-ocr.github.io/tessdoc/Installation.html
781
                ('pytesseract', ['tesseract-ocr']),
782
            ):
NEW
783
                if module in installed_packages:
×
NEW
784
                    importlib.metadata.distribution(module)
×
NEW
785
                    print(f'• {module}')
×
NEW
786
                    print_version(apt_dists)
×
787
        except ImportError:
788
            print('Dependencies cannot be printed as python3-apt is not installed.')
789
            print("Run 'sudo apt-get install python3-apt' to install.")
790
    print()
14✔
791
    return 0
14✔
792

793

794
def main() -> None:  # pragma: no cover
795
    """The entry point run when __name__ == '__main__'.
796

797
    Contains all the high-level logic to instantiate all classes that run the program.
798

799
    :raises NotImplementedError: If a `--database-engine` is specified that is not supported.
800
    :raises RuntimeError: If `--database-engine redis` is selected but `--cache` with a redis URI is not provided.
801
    """
802
    # Make sure that PendingDeprecationWarning are displayed from all modules (otherwise only those in __main__ are)
803
    warnings.filterwarnings('default', category=PendingDeprecationWarning)
804

805
    # Issue deprecation warning if running on minimum version supported
806
    python_version_warning()
807

808
    # Path where the config, jobs and hooks files are located
809
    if sys.platform != 'win32':
810
        config_path = platformdirs.user_config_path(__project_name__)  # typically ~/.config/{__project_name__}
811
    else:
812
        config_path = platformdirs.user_documents_path().joinpath(__project_name__)
813

814
    # Path where the snapshot database is located; typically ~/.local/share/{__project_name__} or
815
    # $XDG_DATA_HOME/{__project_name__} # in linux, ~/Library/Application Support/webchanges in macOS  and
816
    # or %LOCALAPPDATA%\{__project_name__}\{__project_name__} in Windows
817
    data_path = platformdirs.user_data_path(__project_name__, __project_name__.capitalize())
818

819
    # Default config, jobs, hooks and ssdb (database) files
820
    default_config_file = config_path.joinpath('config.yaml')
821
    default_jobs_file = config_path.joinpath('jobs.yaml')
822
    default_hooks_file = config_path.joinpath('hooks.py')
823
    default_ssdb_file = data_path.joinpath('snapshots.db')
824

825
    # Check for and if found migrate snapshot database file from version <= 3.21, which was called cache.db and located
826
    # in user_cache_path
827
    migrate_from_legacy('webchanges', ssdb_file=default_ssdb_file)
828

829
    # Check for and if found migrate legacy (urlwatch) files
830
    migrate_from_legacy('urlwatch', default_config_file, default_jobs_file, default_hooks_file, default_ssdb_file)
831

832
    # Parse command line arguments
833
    command_config = CommandConfig(
834
        sys.argv[1:],
835
        config_path,
836
        default_config_file,
837
        default_jobs_file,
838
        default_hooks_file,
839
        default_ssdb_file,
840
    )
841

842
    # Set up the logger to verbose if needed
843
    setup_logger(command_config.verbose, command_config.log_file)
844

845
    # log defaults
846
    logger.debug(f'Default config path is {config_path}')
847
    logger.debug(f'Default data path is {data_path}')
848

849
    # For speed, run these here
850
    handle_unitialized_actions(command_config, default_config_file)
851

852
    # Only now, after configuring logging, we can load other modules
853
    from webchanges.command import UrlwatchCommand
854
    from webchanges.main import Urlwatch
855
    from webchanges.storage import (
856
        SsdbDirStorage,
857
        SsdbRedisStorage,
858
        SsdbSQLite3Storage,
859
        SsdbStorage,
860
        YamlConfigStorage,
861
        YamlJobsStorage,
862
    )
863

864
    # Locate config, jobs, hooks and database files
865
    command_config.config_file = locate_storage_file(
866
        filename=command_config.config_file,
867
        default_path=command_config.config_path,
868
        ext='.yaml',
869
        prefix='config',
870
    )
871
    command_config.jobs_files = locate_glob_files(
872
        filenames=command_config.jobs_files,
873
        default_path=command_config.config_path,
874
        ext='.yaml',
875
        prefix='jobs',
876
    )
877
    command_config.hooks_files = locate_glob_files(
878
        filenames=command_config.hooks_files,
879
        default_path=command_config.config_path,
880
        ext='.py',
881
        prefix='hooks',
882
    )
883
    command_config.ssdb_file = locate_storage_file(
884
        filename=command_config.ssdb_file,
885
        default_path=data_path,
886
        ext='.db',
887
    )
888

889
    # Deploy bundled JSON schemas next to the user's config.yaml / jobs.yaml so editors can autocomplete and
890
    # validate.
891
    sync_bundled_schemas(command_config)
892

893
    # Check for first run
894
    if command_config.config_file == default_config_file and not Path(command_config.config_file).is_file():
895
        first_run(command_config)
896

897
    # Setup config file API
898
    config_storage = YamlConfigStorage(command_config.config_file)  # storage.py
899

900
    # load config (which for syntax checking requires hooks to be loaded too)
901
    if command_config.hooks_files:
902
        logger.debug(f'Hooks files to be loaded: {command_config.hooks_files}')
903
        for hooks_file in command_config.hooks_files:
904
            load_hooks(hooks_file, is_default=not command_config.hooks_files_inputted)
905
    config_storage.load()
906

907
    # Setup database API
908
    database_engine = command_config.database_engine or config_storage.config.get('database', {}).get('engine')
909
    max_snapshots = command_config.max_snapshots or config_storage.config.get('database', {}).get('max_snapshots')
910
    if database_engine == 'sqlite3':
911
        ssdb_storage: SsdbStorage = SsdbSQLite3Storage(command_config.ssdb_file, max_snapshots)  # storage.py
912
    elif any(str(command_config.ssdb_file).startswith(prefix) for prefix in ('redis://', 'rediss://')):
913
        ssdb_storage = SsdbRedisStorage(command_config.ssdb_file)  # storage.py
914
    elif database_engine.startswith('redis'):
915
        ssdb_storage = SsdbRedisStorage(database_engine)
916
    elif database_engine == 'textfiles':
917
        ssdb_storage = SsdbDirStorage(command_config.ssdb_file)  # storage.py
918
    elif database_engine == 'minidb':
919
        # legacy code imported only if needed (requires minidb, which is not a dependency)
920
        from webchanges.storage_minidb import SsdbMiniDBStorage
921

922
        ssdb_storage = SsdbMiniDBStorage(command_config.ssdb_file)  # storage.py
923
    else:
924
        raise NotImplementedError(f'Database engine {database_engine} not implemented')
925

926
    # Setup jobs file API
927
    jobs_storage = YamlJobsStorage(command_config.jobs_files)  # storage.py
928

929
    # Setup 'webchanges'
930
    urlwatcher = Urlwatch(command_config, config_storage, ssdb_storage, jobs_storage)  # main.py
931
    urlwatch_command = UrlwatchCommand(urlwatcher)  # command.py
932

933
    # Run 'webchanges', starting with processing command line arguments
934
    urlwatch_command.run()
935

936
    # Remove Playwright debug mode if there
937
    teardown_logger(command_config.verbose)
938

939

940
if __name__ == '__main__':
941
    main()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc