diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 3224a0e..26a6a7e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -35,6 +35,7 @@ jobs: - name: Run packaging step env: UV_PYTHON: "3.12" + ISIC_CLI_SENTRY_DSN: ${{ secrets.ISIC_CLI_SENTRY_DSN }} run: | uv run tox -e package @@ -68,6 +69,8 @@ jobs: - name: Run packaging step run: | uv run tox -e package + env: + ISIC_CLI_SENTRY_DSN: ${{ secrets.ISIC_CLI_SENTRY_DSN }} - name: Zip and upload binary run: | .github/zip_and_upload_package.sh ${{ runner.os }} ${{ github.event.release.tag_name }} diff --git a/isic_cli/cli/__init__.py b/isic_cli/cli/__init__.py index 9755024..69c176d 100644 --- a/isic_cli/cli/__init__.py +++ b/isic_cli/cli/__init__.py @@ -37,6 +37,12 @@ from isic_cli.session import get_session from isic_cli.utils.version import check_for_newer_version, get_version, is_dev_install +# the DSN is kept out of the source. this module only exists in the pyinstaller binaries. +try: + from _isic_cli_sentry_dsn import SENTRY_DSN # pyright: ignore[reportMissingImports] +except ImportError: + SENTRY_DSN: str | None = None + if TYPE_CHECKING: from collections.abc import Iterator @@ -46,8 +52,6 @@ "prod": "https://api.isic-archive.com", } -SENTRY_DSN = "https://3c3afa5c12e04042979583df1a07abd2@o267860.ingest.sentry.io/6645383" - logger = logging.getLogger("isic_cli") @@ -59,7 +63,9 @@ def _sentry_atexit_display(pending: int, timeout: int) -> None: def _sentry_setup(): - if not is_dev_install(): + # don't pass a missing dsn to sentry_sdk.init, it would fall back to the generic SENTRY_DSN + # environment variable which may belong to an unrelated project. + if SENTRY_DSN and not is_dev_install(): sentry_sdk.init( SENTRY_DSN, release=str(get_version()), @@ -245,9 +251,10 @@ def _report_unexpected_errors(ctx: click.Context) -> Iterator[None]: sys.exit(1) # the prompt can't be answered without an interactive terminal (e.g. cron, CI, or piped - # input), so only point to the issue tracker. + # input), and a bug report can't be sent without a DSN (e.g. a pip install), so only + # point to the issue tracker. send_bug_report = "n" - if sys.stdin.isatty(): + if sys.stdin.isatty() and sentry_sdk.is_initialized(): send_bug_report = click.prompt( click.style( "This is a bug in isic-cli, would you like to send a bug report?", fg="yellow" diff --git a/pyproject.toml b/pyproject.toml index c2fcb4b..8c0c486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "requests", "retryable-requests", "rich", - "sentry-sdk", + "sentry-sdk>=2.0.0", "tenacity", ] dynamic = ["version"] @@ -115,6 +115,9 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] +"scripts/**" = [ + "INP001", # File is part of an implicit namespace package +] "tests/**" = [ "PLR0913", # Too many arguments to function call "PLR2004", # Magic value used in comparison diff --git a/scripts/write_sentry_dsn_module.py b/scripts/write_sentry_dsn_module.py new file mode 100644 index 0000000..4903c75 --- /dev/null +++ b/scripts/write_sentry_dsn_module.py @@ -0,0 +1,24 @@ +"""Write a module that embeds the Sentry DSN into the PyInstaller binary.""" + +from __future__ import annotations + +import os +from pathlib import Path +import sys + +SENTRY_DSN_ENV_VAR = "ISIC_CLI_SENTRY_DSN" + + +def main(): + module_path = Path(sys.argv[1]) + dsn = os.environ.get(SENTRY_DSN_ENV_VAR) + + if not dsn: + raise SystemExit(f"{SENTRY_DSN_ENV_VAR} must be set to embed it in the binary.") + + module_path.parent.mkdir(parents=True, exist_ok=True) + module_path.write_text(f"SENTRY_DSN = {dsn!r}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli_base.py b/tests/test_cli_base.py index 40b8949..ab62eb8 100644 --- a/tests/test_cli_base.py +++ b/tests/test_cli_base.py @@ -39,14 +39,17 @@ def test_new_version( @pytest.mark.parametrize( - ("interactive", "send_bug_report", "capture_exception_sent"), + ("interactive", "sentry_initialized", "send_bug_report", "capture_exception_sent"), [ - (True, "y", 1), - (True, "n", 0), - (False, None, 0), + (True, True, "y", 1), + (True, True, "n", 0), + (False, True, None, 0), + (True, False, None, 0), ], ) -def test_sentry_error_capture(mocker, capsys, interactive, send_bug_report, capture_exception_sent): +def test_sentry_error_capture( + mocker, capsys, interactive, sentry_initialized, send_bug_report, capture_exception_sent +): # Note: _sentry_setup is always mocked from isic_cli import cli from isic_cli.cli import main @@ -59,13 +62,14 @@ def test_sentry_error_capture(mocker, capsys, interactive, send_bug_report, capt mocker.patch("isic_cli.cli.is_dev_install", return_value=False) stdin = mocker.patch.object(sys, "stdin") stdin.isatty.return_value = interactive + mocker.patch("isic_cli.cli.sentry_sdk.is_initialized", return_value=sentry_initialized) spy = mocker.spy(cli, "capture_exception") with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 1 - assert prompt.called == interactive + assert prompt.called == (interactive and sentry_initialized) assert spy.call_count == capture_exception_sent issue_link_shown = ( "https://github.com/ImageMarkup/isic-cli/issues/new" in capsys.readouterr().err diff --git a/tox.ini b/tox.ini index 3f92993..32c910f 100644 --- a/tox.ini +++ b/tox.ini @@ -41,14 +41,18 @@ commands = [testenv:package] # PyInstaller needs the real package files in site-packages, not an editable install package = wheel +passenv = + ISIC_CLI_SENTRY_DSN dependency_groups = package commands = + python scripts/write_sentry_dsn_module.py {env_tmp_dir}/sentry_dsn/_isic_cli_sentry_dsn.py pyinstaller \ --clean \ --noconfirm \ --onefile \ --name isic \ + --paths {env_tmp_dir}/sentry_dsn \ --recursive-copy-metadata isic_cli \ --collect-data isic_cli \ --specpath {env_tmp_dir} \ diff --git a/uv.lock b/uv.lock index e89ef38..c95e6bc 100644 --- a/uv.lock +++ b/uv.lock @@ -507,7 +507,7 @@ requires-dist = [ { name = "requests" }, { name = "retryable-requests" }, { name = "rich" }, - { name = "sentry-sdk" }, + { name = "sentry-sdk", specifier = ">=2.0.0" }, { name = "tenacity" }, ]