From 312d38687a3d6f12cf23b0e8650b45656276845f Mon Sep 17 00:00:00 2001 From: Kunal Mehta Date: Wed, 10 Apr 2024 10:56:50 -0400 Subject: [PATCH 1/2] Drop bullseye support We are moving to bookworm, so we can set Python 3.11 as the minimum version to support and avoid installing poetry from pip. There's one FIXME in the piuparts job because we don't have bookworm on apt.freedom.press yet. Using the bullseye repository should be fine for now. --- .github/workflows/build.yml | 5 -- .github/workflows/ci.yml | 31 +-------- .github/workflows/nightlies.yml | 2 - .github/workflows/piuparts/Dockerfile | 2 +- .github/workflows/piuparts/run-piuparts.sh | 3 +- .github/workflows/sdk.yml | 2 +- .github/workflows/test.yml | 45 +----------- client/Makefile | 7 +- client/poetry.lock | 79 ++-------------------- client/pyproject.toml | 8 +-- export/poetry.lock | 24 +------ export/pyproject.toml | 4 +- log/poetry.lock | 16 +---- log/pyproject.toml | 4 +- poetry.lock | 16 +---- proxy/poetry.lock | 17 +---- proxy/pyproject.toml | 2 +- pyproject.toml | 4 +- scripts/Dockerfile | 2 +- scripts/build-debs.sh | 4 +- 20 files changed, 37 insertions(+), 240 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1e923cfc..f467fed3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,6 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest container: debian:${{ matrix.debian_version }} @@ -50,7 +49,6 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest outputs: @@ -78,7 +76,6 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest outputs: @@ -104,7 +101,6 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest container: debian:bookworm @@ -142,7 +138,6 @@ jobs: - log - proxy debian_version: - - bullseye - bookworm runs-on: ubuntu-latest needs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 215b36604..966af2da0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest container: debian:${{ matrix.debian_version }} @@ -34,28 +33,15 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest container: debian:${{ matrix.debian_version }} steps: - run: | - apt-get update && apt-get install --yes git make file + apt-get update && apt-get install --yes git make file python3-poetry - uses: actions/checkout@v4 - name: Install dependencies run: | - source /etc/os-release - if [[ "$VERSION_CODENAME" == "bullseye" ]]; then - # Install Poetry via PyPI - apt-get install --yes --no-install-recommends python3-pip - pip install poetry==1.6.1 - elif [[ "$VERSION_CODENAME" == "bookworm" ]]; then - # Install Poetry via system package - apt-get install --yes --no-install-recommends python3-poetry - else - echo "Unsupported Debian version: $VERSION_CODENAME" - exit 1 - fi poetry install - name: Run lint run: make lint @@ -71,7 +57,6 @@ jobs: - log - proxy debian_version: - - bullseye - bookworm # bookworm jobs are failing and will be # replaced with proxy v2 shortly, so skip @@ -83,22 +68,10 @@ jobs: container: debian:${{ matrix.debian_version }} steps: - run: | - apt-get update && apt-get install --yes git make gnupg + apt-get update && apt-get install --yes git make gnupg python3-poetry - uses: actions/checkout@v4 - name: Install dependencies run: | - source /etc/os-release - if [[ "$VERSION_CODENAME" == "bullseye" ]]; then - # Install Poetry via PyPI - apt-get install --yes --no-install-recommends python3-pip - pip install poetry==1.6.1 - elif [[ "$VERSION_CODENAME" == "bookworm" ]]; then - # Install Poetry via system package - apt-get install --yes --no-install-recommends python3-poetry - else - echo "Unsupported Debian version: $VERSION_CODENAME" - exit 1 - fi poetry -C ${{ matrix.component }} install if [[ "${{ matrix.component }}" == "client" ]]; then make -C ${{ matrix.component }} ci-install-deps diff --git a/.github/workflows/nightlies.yml b/.github/workflows/nightlies.yml index e8c0443e1..d08c0f4a6 100644 --- a/.github/workflows/nightlies.yml +++ b/.github/workflows/nightlies.yml @@ -21,7 +21,6 @@ jobs: fail-fast: false matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest outputs: @@ -81,7 +80,6 @@ jobs: git push origin main # Now the packages themselves cd ../securedrop-apt-test - cp -v ../build-bullseye/*.deb workstation/bullseye-nightlies/ cp -v ../build-bookworm/*.deb workstation/bookworm-nightlies/ git add . git diff-index --quiet HEAD || git commit -m "Automated SecureDrop workstation build" diff --git a/.github/workflows/piuparts/Dockerfile b/.github/workflows/piuparts/Dockerfile index 127245f58..9dce38b6b 100644 --- a/.github/workflows/piuparts/Dockerfile +++ b/.github/workflows/piuparts/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=bullseye +ARG DISTRO=bookworm FROM debian:$DISTRO RUN apt-get update && apt-get upgrade --yes && apt-get install -y ca-certificates diff --git a/.github/workflows/piuparts/run-piuparts.sh b/.github/workflows/piuparts/run-piuparts.sh index 1cf0370fc..1497a59cb 100644 --- a/.github/workflows/piuparts/run-piuparts.sh +++ b/.github/workflows/piuparts/run-piuparts.sh @@ -11,9 +11,10 @@ docker build . --build-arg DISTRO="$DISTRO" -t ourimage # TODO: Our currently released packages don't install with piuparts, so we pass # --no-upgrade-test to avoid installing them and testing the upgrade path. Once # they do we can remove that line. +# FIXME: switch --extra-repo to bookworm once it exists piuparts --docker-image ourimage \ --distribution "$DISTRO" \ - --extra-repo 'deb [signed-by=/usr/share/keyrings/securedrop-keyring.gpg] https://apt.freedom.press bullseye main' \ + --extra-repo "deb [signed-by=/usr/share/keyrings/securedrop-keyring.gpg] https://apt.freedom.press bullseye main" \ --warn-on-leftovers-after-purge \ --no-upgrade-test \ /build/securedrop-"${PACKAGE}"*.deb diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index ee1ba0c91..4faba0672 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -26,7 +26,7 @@ jobs: path: "securedrop-server" - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install dependencies run: | pip install poetry==1.6.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ce8fe19a..b9c5e5d11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,6 @@ jobs: - log - proxy debian_version: - - bullseye - bookworm # bookworm jobs are failing and will be # replaced with proxy v2 shortly, so skip @@ -36,7 +35,7 @@ jobs: container: debian:${{ matrix.debian_version }} steps: - run: | - apt-get update && apt-get install --yes git make gnupg sudo + apt-get update && apt-get install --yes git make gnupg sudo python3-poetry - uses: actions/checkout@v4 - name: Setup user run: | @@ -44,18 +43,6 @@ jobs: useradd --create-home --shell /bin/bash user - name: Install dependencies run: | - source /etc/os-release - if [[ "$VERSION_CODENAME" == "bullseye" ]]; then - # Install Poetry via PyPI - apt-get install --yes --no-install-recommends python3-pip - pip install poetry==1.6.1 - elif [[ "$VERSION_CODENAME" == "bookworm" ]]; then - # Install Poetry via system package - apt-get install --yes --no-install-recommends python3-poetry - else - echo "Unsupported Debian version: $VERSION_CODENAME" - exit 1 - fi sudo -u user poetry -C ${{ matrix.component }} install - name: Run test run: | @@ -73,13 +60,12 @@ jobs: - test-integration - test-random debian_version: - - bullseye - bookworm runs-on: ubuntu-latest container: debian:${{ matrix.debian_version }} steps: - run: | - apt-get update && apt-get install --yes git make gnupg sudo + apt-get update && apt-get install --yes git make gnupg sudo python3-poetry - uses: actions/checkout@v4 - name: Setup user run: | @@ -87,18 +73,6 @@ jobs: useradd --create-home --shell /bin/bash user - name: Install dependencies run: | - source /etc/os-release - if [[ "$VERSION_CODENAME" == "bullseye" ]]; then - # Install Poetry via PyPI - apt-get install --yes --no-install-recommends python3-pip - pip install poetry==1.6.1 - elif [[ "$VERSION_CODENAME" == "bookworm" ]]; then - # Install Poetry via system package - apt-get install --yes --no-install-recommends python3-poetry - else - echo "Unsupported Debian version: $VERSION_CODENAME" - exit 1 - fi make -C client ci-install-deps sudo -u user poetry -C client install - name: Run test @@ -111,28 +85,15 @@ jobs: strategy: matrix: debian_version: - - bullseye - bookworm runs-on: ubuntu-latest container: debian:${{ matrix.debian_version }} steps: - run: | - apt-get update && apt-get install --yes git make + apt-get update && apt-get install --yes git make python3-poetry - uses: actions/checkout@v4 - name: Install dependencies run: | - source /etc/os-release - if [[ "$VERSION_CODENAME" == "bullseye" ]]; then - # Install Poetry via PyPI - apt-get install --yes --no-install-recommends python3-pip - pip install poetry==1.6.1 - elif [[ "$VERSION_CODENAME" == "bookworm" ]]; then - # Install Poetry via system package - apt-get install --yes --no-install-recommends python3-poetry - else - echo "Unsupported Debian version: $VERSION_CODENAME" - exit 1 - fi poetry -C client install make -C client ci-install-deps git config --global --add safe.directory '*' diff --git a/client/Makefile b/client/Makefile index 032bec86d..2f89a1cc8 100644 --- a/client/Makefile +++ b/client/Makefile @@ -1,11 +1,10 @@ .PHONY: all all: help -# We prefer to use python3.9 if it's availabe, especially on arm64 based Macs, +# We prefer to use python3.11 if it's available, especially on arm64 based Macs, # which would not be able to install the virtual environment without an x86_64 -# Python 3.9, but we're also OK with just python3 if that's all we've got -PYTHON := $(if $(shell bash -c "command -v python3.9"), python3.9, python3) -VERSION_CODENAME ?= bullseye +# Python 3.11, but we're also OK with just python3 if that's all we've got +PYTHON := $(if $(shell bash -c "command -v python3.11"), python3.11, python3) SEMGREP_FLAGS := --exclude "tests/" --error --strict --verbose diff --git a/client/poetry.lock b/client/poetry.lock index 493756eec..ba21a6fd2 100644 --- a/client/poetry.lock +++ b/client/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "alembic" @@ -210,9 +210,6 @@ files = [ {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli"] @@ -268,20 +265,6 @@ files = [ {file = "entrypoint2-1.1.tar.gz", hash = "sha256:fc0b7fe7b21acdab47a585ab9407ca7e5c4f96cb6888575db6b0ceb91f0e105a"}, ] -[[package]] -name = "exceptiongroup" -version = "1.1.3" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "face" version = "22.0.0" @@ -812,7 +795,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] @@ -1097,23 +1079,6 @@ files = [ {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, ] -[[package]] -name = "pyqt5" -version = "5.15.2" -description = "Python bindings for the Qt cross platform application toolkit" -optional = false -python-versions = ">=3.5" -files = [ - {file = "PyQt5-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-abi3-macosx_10_13_intel.whl", hash = "sha256:894ca4ae767a8d6cf5903784b71f755073c78cb8c167eecf6e4ed6b3b055ac6a"}, - {file = "PyQt5-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:29889845688a54d62820585ad5b2e0200a36b304ff3d7a555e95599f110ba4ce"}, - {file = "PyQt5-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win32.whl", hash = "sha256:ea24f24b7679bf393dd2e4f53fe0ce65021be18304c1ff7a226c2fc5c356d0da"}, - {file = "PyQt5-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win_amd64.whl", hash = "sha256:faaecb76ec65e12673a968e7f5bc02495957e6996f0a3fa0d98895f9e4113746"}, - {file = "PyQt5-5.15.2.tar.gz", hash = "sha256:372b08dc9321d1201e4690182697c5e7ffb2e0770e6b4a45519025134b12e4fc"}, -] - -[package.dependencies] -PyQt5-sip = ">=12.8,<13" - [[package]] name = "pyqt5" version = "5.15.9" @@ -1145,36 +1110,6 @@ files = [ {file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"}, ] -[[package]] -name = "pyqt5-sip" -version = "12.8.1" -description = "The sip module support for PyQt5" -optional = false -python-versions = ">=3.5" -files = [ - {file = "PyQt5_sip-12.8.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:bb5a87b66fc1445915104ee97f7a20a69decb42f52803e3b0795fa17ff88226c"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a29e2ac399429d3b7738f73e9081e50783e61ac5d29344e0802d0dcd6056c5a2"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-win32.whl", hash = "sha256:0304ca9114b9817a270f67f421355075b78ff9fc25ac58ffd72c2601109d2194"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-win_amd64.whl", hash = "sha256:84ba7746762bd223bed22428e8561aa267a229c28344c2d28c5d5d3f8970cffb"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7b81382ce188d63890a0e35abe0f9bb946cabc873a31873b73583b0fc84ac115"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b6d42250baec52a5f77de64e2951d001c5501c3a2df2179f625b241cbaec3369"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-win32.whl", hash = "sha256:6c1ebee60f1d2b3c70aff866b7933d8d8d7646011f7c32f9321ee88c290aa4f9"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:34dcd29be47553d5f016ff86e89e24cbc5eebae92eb2f96fb32d2d7ba028c43c"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ed897c58acf4a3cdca61469daa31fe6e44c33c6c06a37c3f21fab31780b3b86a"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a1b8ef013086e224b8e86c93f880f776d01b59195bdfa2a8e0b23f0480678fec"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-win32.whl", hash = "sha256:0cd969be528c27bbd4755bd323dff4a79a8fdda28215364e6ce3e069cb56c2a9"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c9800729badcb247765e4ffe2241549d02da1fa435b9db224845bc37c3e99cb0"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9312ec47cac4e33c11503bc1cbeeb0bdae619620472f38e2078c5a51020a930f"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2f35e82fd7ec1e1f6716e9154721c7594956a4f5bd4f826d8c6a6453833cc2f0"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-win32.whl", hash = "sha256:da9c9f1e65b9d09e73bd75befc82961b6b61b5a3b9d0a7c832168e1415f163c6"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:832fd60a264de4134c2824d393320838f3ab648180c9c357ec58a74524d24507"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c317ab1263e6417c498b81f5c970a9b1af7acefab1f80b4cc0f2f8e661f29fc5"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c9d6d448c29dc6606bb7974696608f81f4316c8234f7c7216396ed110075e777"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-win32.whl", hash = "sha256:5a011aeff89660622a6d5c3388d55a9d76932f3b82c95e82fc31abd8b1d2990d"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:f168f0a7f32b81bfeffdf003c36f25d81c97dee5eb67072a5183e761fe250f13"}, - {file = "PyQt5_sip-12.8.1.tar.gz", hash = "sha256:30e944db9abee9cc757aea16906d4198129558533eb7fadbe48c5da2bd18e0bd"}, -] - [[package]] name = "pyqt5-sip" version = "12.11.1" @@ -1257,10 +1192,7 @@ files = [ ] [package.dependencies] -Pillow = [ - {version = ">=9.2.0", markers = "python_version == \"3.10\" or python_version == \"3.9\""}, - {version = ">=9.3.0", markers = "python_version == \"3.11\""}, -] +Pillow = {version = ">=9.3.0", markers = "python_version == \"3.11\""} pyscreenshot = "*" [[package]] @@ -1276,11 +1208,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -1944,7 +1874,6 @@ files = [ [package.dependencies] PyYAML = "*" -urllib3 = {version = "<2", markers = "python_version < \"3.10\""} wrapt = "*" yarl = "*" @@ -2135,5 +2064,5 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "4ea5726aefb06b50c8591fd4b016b29902de4a8ec3339a9a068fce6b2e6b0977" +python-versions = "^3.11" +content-hash = "545a37b0e6c8a2772a9809202c9b1837256efc6de01f224304b8ac93058a4c40" diff --git a/client/pyproject.toml b/client/pyproject.toml index b260366e3..f33f0a1d1 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -7,7 +7,7 @@ license = "AGPLv3+" readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" Jinja2 = "3.1.3" SQLAlchemy = "^1.3.3" alembic = "^1.1.0" @@ -19,12 +19,10 @@ requests = "^2.31.0" # In production these two are installed using a system package # so match those versions exactly PyQt5 = [ - {version = "=5.15.9", python = ">=3.10"}, # bookworm - {version = "=5.15.2", python = "< 3.10"}, # bullseye + {version = "=5.15.9", python = ">=3.11"}, # bookworm ] PyQt5-sip = [ - {version = "=12.11.1", python = ">=3.10"}, # bookworm - {version = "=12.8.1", python = "< 3.10"}, # bullseye + {version = "=12.11.1", python = ">=3.11"}, # bookworm ] PyAutoGUI = "*" babel = "^2.12.1" diff --git a/export/poetry.lock b/export/poetry.lock index f95e987ae..679a82449 100644 --- a/export/poetry.lock +++ b/export/poetry.lock @@ -255,9 +255,6 @@ files = [ {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] toml = ["tomli"] @@ -272,20 +269,6 @@ files = [ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[[package]] -name = "exceptiongroup" -version = "1.1.3" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "face" version = "22.0.0" @@ -449,7 +432,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] @@ -556,11 +538,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -951,5 +931,5 @@ bracex = ">=2.1.1" [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "8cf497486d8ecbba7be2591f9777c4d8374e07ed434f5f1290f0e61b8c03d3cb" +python-versions = "^3.11" +content-hash = "f5ac402cc8ee43335601611650cd1f3e240a7c7ad49e1c1c5f5d338c539d1af3" diff --git a/export/pyproject.toml b/export/pyproject.toml index 8b1334320..1d7ba84f1 100644 --- a/export/pyproject.toml +++ b/export/pyproject.toml @@ -7,7 +7,7 @@ license = "GPLv3+" readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" pexpect = "^4.9.0" [tool.poetry.group.dev.dependencies] @@ -20,4 +20,4 @@ semgrep = "^1.31.2" types-pexpect = "^4.9.0.20240207" [tool.mypy] -python_version = "3.9" +python_version = "3.11" diff --git a/log/poetry.lock b/log/poetry.lock index 65a92c945..9f0cb1672 100644 --- a/log/poetry.lock +++ b/log/poetry.lock @@ -38,7 +38,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] @@ -71,17 +70,6 @@ files = [ [package.extras] hiredis = ["hiredis (>=0.1.3)"] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "types-redis" version = "3.5.18" @@ -117,5 +105,5 @@ files = [ [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "b90f0a7c2e6f6e82931339ec7f0100e2af7fe59ef918726f411e192a07809196" +python-versions = "^3.11" +content-hash = "73af35c8ca19564e95ac79b3809647ab81448cd0eaf4943a35eaebda26e75315" diff --git a/log/pyproject.toml b/log/pyproject.toml index 47eb2ffdd..fffd03115 100644 --- a/log/pyproject.toml +++ b/log/pyproject.toml @@ -7,7 +7,7 @@ license = "GPLv3+" readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" redis = "=3.3.11" [tool.poetry.group.dev.dependencies] @@ -16,7 +16,7 @@ types-redis = "<4" types-setuptools = "^68.0.0" [tool.mypy] -python_version = "3.9" +python_version = "3.11" scripts_are_modules = true files = [ "*.py", diff --git a/poetry.lock b/poetry.lock index e86a26e62..216d141fa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -280,7 +280,6 @@ files = [ [package.dependencies] packaging = "*" -tomli = {version = "*", markers = "python_version < \"3.11\""} [package.extras] all = ["dparse[conda]", "dparse[pipenv]", "dparse[poetry]"] @@ -752,17 +751,6 @@ files = [ {file = "shellcheck_py-0.10.0.1.tar.gz", hash = "sha256:390826b340b8c19173922b0da5ef7b66ef34d4d087dc48aad3e01f7e77e164d9"}, ] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "typer" version = "0.9.0" @@ -814,5 +802,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "bd9ad05054fe405892ec3ca5ab6001a47930d722fe1a7fd3bdeaf3e26c1e0522" +python-versions = "^3.11" +content-hash = "d467df4e550e69c00b3f401d8c563380fba8b1daa7cf992596c06a96c0b5c499" diff --git a/proxy/poetry.lock b/proxy/poetry.lock index 46e4e4d49..ffa25c4f6 100644 --- a/proxy/poetry.lock +++ b/proxy/poetry.lock @@ -248,7 +248,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] @@ -348,17 +347,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "types-pyyaml" version = "6.0.12.11" @@ -435,7 +423,6 @@ files = [ [package.dependencies] PyYAML = "*" -urllib3 = {version = "<2", markers = "python_version < \"3.10\""} wrapt = "*" yarl = "*" @@ -612,5 +599,5 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "56d28407f6665099eeac3fa2b7ef901426d2f4b1e00829036a50e0ff19c1d824" +python-versions = "^3.11" +content-hash = "9f5b41fd9d1efd38364dd72f803952dbca63d22fe8db8c2cbb2b06201430492f" diff --git a/proxy/pyproject.toml b/proxy/pyproject.toml index 22b8961f4..bdbd639aa 100644 --- a/proxy/pyproject.toml +++ b/proxy/pyproject.toml @@ -7,7 +7,7 @@ license = "GPLv3+" readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" pyyaml = "^5.4.1" requests = "^2.31.0" diff --git a/pyproject.toml b/pyproject.toml index 9633ca411..5baefba3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -requires-python = ">=3.9" +requires-python = ">=3.11" [tool.poetry] name = "client-tools" @@ -9,7 +9,7 @@ authors = ["SecureDrop Team"] license = "AGPLv3+" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" [tool.poetry.group.dev.dependencies] ruff = "^0.4.1" diff --git a/scripts/Dockerfile b/scripts/Dockerfile index b06a5c85c..d565a5ff0 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -1,4 +1,4 @@ -ARG DISTRO=bullseye +ARG DISTRO=bookworm FROM debian:$DISTRO ENV PIP_DISABLE_PIP_VERSION_CHECK=1 diff --git a/scripts/build-debs.sh b/scripts/build-debs.sh index 9886fc528..56b654587 100755 --- a/scripts/build-debs.sh +++ b/scripts/build-debs.sh @@ -3,7 +3,7 @@ # Build packages! This script is configured by environment variables: # `BUILDER`: relative path to the securedrop-builder repository, # defaults to "../securedrop-builder" -# `DEBIAN_VERSION`: codename to build for, defaults to "bullseye" +# `DEBIAN_VERSION`: codename to build for, defaults to "bookworm" # `NIGHTLY`: if set, add current time to the version number # This script runs *outside* the container. @@ -37,7 +37,7 @@ to ${BUILDER} or set the BUILDER variable" fi export BUILDER -export DEBIAN_VERSION="${DEBIAN_VERSION:-bullseye}" +export DEBIAN_VERSION="${DEBIAN_VERSION:-bookworm}" export OCI_RUN_ARGUMENTS export OCI_BIN export CONTAINER="fpf.local/sd-client-builder-${DEBIAN_VERSION}" From 6672504bfbefe5ea81cd0921e8df9c3817d0bd8b Mon Sep 17 00:00:00 2001 From: Kunal Mehta Date: Wed, 10 Apr 2024 11:12:38 -0400 Subject: [PATCH 2/2] Apply ruff fixes for Python 3.11 * Use `T | None` instead of `Optional[T]`. * Also use `T | U` instead of `Union[T, U]` * Use new `datetime.UTC` as shorthand --- client/securedrop_client/api_jobs/base.py | 8 ++-- .../securedrop_client/api_jobs/downloads.py | 24 ++++------ client/securedrop_client/api_jobs/sync.py | 6 +-- .../conversation/transcript/items/factory.py | 6 +-- .../conversation/transcript/items/item.py | 5 +- .../conversation/transcript/items/message.py | 4 +- .../conversation/transcript/transcript.py | 3 +- client/securedrop_client/export.py | 4 +- client/securedrop_client/gui/actions.py | 8 ++-- client/securedrop_client/gui/base/buttons.py | 4 +- client/securedrop_client/gui/base/misc.py | 20 ++++---- .../gui/conversation/export/export_wizard.py | 5 +- .../conversation/export/export_wizard_page.py | 5 +- .../gui/conversation/export/print_dialog.py | 3 +- client/securedrop_client/gui/main.py | 7 ++- client/securedrop_client/gui/widgets.py | 4 +- client/securedrop_client/logic.py | 33 ++++++------- client/securedrop_client/queue.py | 12 ++--- .../securedrop_client/resources/__init__.py | 15 +++--- client/securedrop_client/sdk/__init__.py | 46 +++++++++---------- .../securedrop_client/sdk/sdlocalobjects.py | 5 +- client/securedrop_client/sdk/timestamps.py | 3 +- client/securedrop_client/state/state.py | 12 ++--- client/securedrop_client/storage.py | 16 +++---- client/securedrop_client/sync.py | 9 ++-- client/securedrop_client/utils.py | 16 +++---- client/tests/gui/test_datetime_helpers.py | 6 +-- client/tests/sdk/test_shared.py | 4 +- client/tests/sdk/test_timestamps.py | 8 ++-- client/tests/test_logic.py | 2 +- export/securedrop_export/directory.py | 13 +++--- export/securedrop_export/disk/cli.py | 37 +++++---------- 32 files changed, 155 insertions(+), 198 deletions(-) diff --git a/client/securedrop_client/api_jobs/base.py b/client/securedrop_client/api_jobs/base.py index 15bc663a5..9238e45c4 100644 --- a/client/securedrop_client/api_jobs/base.py +++ b/client/securedrop_client/api_jobs/base.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Optional, TypeVar +from typing import Any, TypeVar from PyQt5.QtCore import QObject, pyqtSignal from sqlalchemy.orm.session import Session @@ -14,7 +14,7 @@ class ApiInaccessibleError(Exception): - def __init__(self, message: Optional[str] = None) -> None: + def __init__(self, message: str | None = None) -> None: if not message: message = ( "API is inaccessible either because there is no client or because the " @@ -26,7 +26,7 @@ def __init__(self, message: Optional[str] = None) -> None: class QueueJob(QObject): def __init__(self, remaining_attempts: int = DEFAULT_NUM_ATTEMPTS) -> None: super().__init__() - self.order_number = None # type: Optional[int] + self.order_number: int | None = None self.remaining_attempts = remaining_attempts def __lt__(self, other: QueueJobType) -> bool: @@ -67,7 +67,7 @@ class ApiJob(QueueJob): def __init__(self, remaining_attempts: int = DEFAULT_NUM_ATTEMPTS) -> None: super().__init__(remaining_attempts) - def _do_call_api(self, api_client: Optional[API], session: Session) -> None: + def _do_call_api(self, api_client: API | None, session: Session) -> None: if not api_client: raise ApiInaccessibleError() diff --git a/client/securedrop_client/api_jobs/downloads.py b/client/securedrop_client/api_jobs/downloads.py index 18b2f1ac1..96eeaace9 100644 --- a/client/securedrop_client/api_jobs/downloads.py +++ b/client/securedrop_client/api_jobs/downloads.py @@ -4,7 +4,7 @@ import math import os from tempfile import NamedTemporaryFile -from typing import Any, Optional, Union +from typing import Any from sqlalchemy.orm.session import Session @@ -26,7 +26,7 @@ class DownloadException(Exception): def __init__( - self, message: str, object_type: Union[type[Reply], type[Message], type[File]], uuid: str + self, message: str, object_type: type[Reply] | type[Message] | type[File], uuid: str ): super().__init__(message) self.object_type = object_type @@ -89,9 +89,7 @@ def _get_realistic_timeout(self, size_in_bytes: int) -> int: timeout = math.ceil((size_in_bytes / TIMEOUT_BYTES_PER_SECOND) * TIMEOUT_ADJUSTMENT_FACTOR) return timeout + TIMEOUT_BASE - def call_download_api( - self, api: API, db_object: Union[File, Message, Reply] - ) -> tuple[str, str]: + def call_download_api(self, api: API, db_object: File | Message | Reply) -> tuple[str, str]: """ Method for making the actual API call to download the file and handling the result. @@ -100,7 +98,7 @@ def call_download_api( """ raise NotImplementedError - def call_decrypt(self, filepath: str, session: Optional[Session] = None) -> str: + def call_decrypt(self, filepath: str, session: Session | None = None) -> str: """ Method for decrypting the file and storing the plaintext result. @@ -110,7 +108,7 @@ def call_decrypt(self, filepath: str, session: Optional[Session] = None) -> str: """ raise NotImplementedError - def get_db_object(self, session: Session) -> Union[File, Message]: + def get_db_object(self, session: Session) -> File | Message: """ Get the database object associated with this job. """ @@ -137,7 +135,7 @@ def call_api(self, api_client: API, session: Session) -> Any: self._decrypt(destination, db_object, session) return db_object.uuid - def _download(self, api: API, db_object: Union[File, Message, Reply], session: Session) -> str: + def _download(self, api: API, db_object: File | Message | Reply, session: Session) -> str: """ Download the encrypted file. Check file integrity and move it to the data directory before marking it as downloaded. @@ -175,9 +173,7 @@ def _download(self, api: API, db_object: Union[File, Message, Reply], session: S f"Failed to download {db_object.uuid}", type(db_object), db_object.uuid ) from e - def _decrypt( - self, filepath: str, db_object: Union[File, Message, Reply], session: Session - ) -> None: + def _decrypt(self, filepath: str, db_object: File | Message | Reply, session: Session) -> None: """ Decrypt the file located at the given filepath and mark it as decrypted. """ @@ -263,7 +259,7 @@ def call_download_api(self, api: API, db_object: Reply) -> tuple[str, str]: api.default_request_timeout = 20 return api.download_reply(sdk_object) - def call_decrypt(self, filepath: str, session: Optional[Session] = None) -> str: + def call_decrypt(self, filepath: str, session: Session | None = None) -> str: """ Override DownloadJob. @@ -317,7 +313,7 @@ def call_download_api(self, api: API, db_object: Message) -> tuple[str, str]: sdk_object, timeout=self._get_realistic_timeout(db_object.size) ) - def call_decrypt(self, filepath: str, session: Optional[Session] = None) -> str: + def call_decrypt(self, filepath: str, session: Session | None = None) -> str: """ Override DownloadJob. @@ -373,7 +369,7 @@ def call_download_api(self, api: API, db_object: File) -> tuple[str, str]: sdk_object, timeout=self._get_realistic_timeout(db_object.size) ) - def call_decrypt(self, filepath: str, session: Optional[Session] = None) -> str: + def call_decrypt(self, filepath: str, session: Session | None = None) -> str: """ Override DownloadJob. diff --git a/client/securedrop_client/api_jobs/sync.py b/client/securedrop_client/api_jobs/sync.py index da109c758..2af97235f 100644 --- a/client/securedrop_client/api_jobs/sync.py +++ b/client/securedrop_client/api_jobs/sync.py @@ -1,6 +1,6 @@ import logging import os -from typing import Any, Optional +from typing import Any from sqlalchemy.orm.session import Session @@ -22,7 +22,7 @@ class MetadataSyncJob(ApiJob): DEFAULT_REQUEST_TIMEOUT = 60 # sec NUMBER_OF_TIMES_TO_RETRY_AN_API_CALL = 2 - def __init__(self, data_dir: str, app_state: Optional[state.State] = None) -> None: + def __init__(self, data_dir: str, app_state: state.State | None = None) -> None: super().__init__(remaining_attempts=self.NUMBER_OF_TIMES_TO_RETRY_AN_API_CALL) self.data_dir = data_dir self._state = app_state @@ -65,7 +65,7 @@ def _update_users(session: Session, remote_users: list[SDKUser]) -> None: 3. Re-associate any draft replies sent by a user that is about to be deleted 4. Delete all remaining local user accounts that no longer exist on the server """ - deleted_user_id = None # type: Optional[int] + deleted_user_id: int | None = None local_users = {user.uuid: user for user in session.query(User).all()} for remote_user in remote_users: local_user = local_users.get(remote_user.uuid) diff --git a/client/securedrop_client/conversation/transcript/items/factory.py b/client/securedrop_client/conversation/transcript/items/factory.py index 548473266..0e6ec6ed4 100644 --- a/client/securedrop_client/conversation/transcript/items/factory.py +++ b/client/securedrop_client/conversation/transcript/items/factory.py @@ -1,5 +1,3 @@ -from typing import Optional - from securedrop_client import db as database from .file import File @@ -7,8 +5,8 @@ from .message import Message -def transcribe(record: database.Base) -> Optional[Item]: - if isinstance(record, (database.Message, database.Reply)): +def transcribe(record: database.Base) -> Item | None: + if isinstance(record, database.Message | database.Reply): return Message(record) if isinstance(record, database.File): return File(record) diff --git a/client/securedrop_client/conversation/transcript/items/item.py b/client/securedrop_client/conversation/transcript/items/item.py index d78082164..ca2582d04 100644 --- a/client/securedrop_client/conversation/transcript/items/item.py +++ b/client/securedrop_client/conversation/transcript/items/item.py @@ -1,6 +1,3 @@ -from typing import Optional - - class Item: """ A transcript item. @@ -8,4 +5,4 @@ class Item: Transcript items must define their `type` to be rendered by the transcript template. """ - type: Optional[str] = None + type: str | None = None diff --git a/client/securedrop_client/conversation/transcript/items/message.py b/client/securedrop_client/conversation/transcript/items/message.py index 6bb83cc5a..8b1288948 100644 --- a/client/securedrop_client/conversation/transcript/items/message.py +++ b/client/securedrop_client/conversation/transcript/items/message.py @@ -1,5 +1,3 @@ -from typing import Union - from securedrop_client import db as database from .item import Item @@ -8,7 +6,7 @@ class Message(Item): type = "message" - def __init__(self, record: Union[database.Message, database.Reply]): + def __init__(self, record: database.Message | database.Reply): super().__init__() self.content = record.content diff --git a/client/securedrop_client/conversation/transcript/transcript.py b/client/securedrop_client/conversation/transcript/transcript.py index b587a69f0..f267b7aa6 100644 --- a/client/securedrop_client/conversation/transcript/transcript.py +++ b/client/securedrop_client/conversation/transcript/transcript.py @@ -1,5 +1,4 @@ import gettext -from typing import Optional from jinja2 import Environment, PackageLoader, select_autoescape @@ -19,7 +18,7 @@ env.install_gettext_translations(gettext) # type: ignore[attr-defined] -def transcribe(record: database.Base) -> Optional[Item]: +def transcribe(record: database.Base) -> Item | None: return transcribe_item(record) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 7a2f0b6cd..d06ea4329 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -3,10 +3,10 @@ import os import shutil import tarfile +from collections.abc import Callable from io import BytesIO from shlex import quote from tempfile import mkdtemp -from typing import Callable, Optional from PyQt5.QtCore import QObject, QProcess, pyqtSignal @@ -100,7 +100,7 @@ def run_export_preflight_checks(self) -> None: logger.error("Export preflight check failed during archive creation") self._on_export_process_error() - def export(self, filepaths: list[str], passphrase: Optional[str]) -> None: + def export(self, filepaths: list[str], passphrase: str | None) -> None: """ Bundle filepaths into a tarball and send to encrypted USB via qrexec, optionally supplying a passphrase to unlock encrypted drives. diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index ba6bc3dd4..e3edd79cc 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -5,10 +5,10 @@ the GUI and the controller. """ +from collections.abc import Callable from contextlib import ExitStack from gettext import gettext as _ from pathlib import Path -from typing import Callable, Optional from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import QAction, QApplication, QDialog, QMenu @@ -32,7 +32,7 @@ class DownloadConversation(QAction): """Download all files and messages of the currently selected conversation.""" def __init__( - self, parent: QMenu, controller: Controller, app_state: Optional[state.State] = None + self, parent: QMenu, controller: Controller, app_state: state.State | None = None ) -> None: self._controller = controller self._state = app_state @@ -112,7 +112,7 @@ def __init__( parent: QMenu, controller: Controller, confirmation_dialog: Callable[[Source], QDialog], - app_state: Optional[state.State] = None, + app_state: state.State | None = None, ) -> None: self.source = source self.controller = controller @@ -245,7 +245,7 @@ def __init__( parent: QMenu, controller: Controller, source: Source, - app_state: Optional[state.State] = None, + app_state: state.State | None = None, ) -> None: """ Allows export of a conversation transcript and all is files. Will download any file diff --git a/client/securedrop_client/gui/base/buttons.py b/client/securedrop_client/gui/base/buttons.py index 16d2493da..5d61f0979 100644 --- a/client/securedrop_client/gui/base/buttons.py +++ b/client/securedrop_client/gui/base/buttons.py @@ -27,7 +27,7 @@ along with this program. If not, see . """ -from typing import NewType, Optional +from typing import NewType from PyQt5.QtWidgets import QPushButton, QWidget @@ -40,7 +40,7 @@ class SDPushButton(QPushButton): Alignment = NewType("Alignment", str) AlignLeft = Alignment("left-aligned") - def __init__(self, parent: Optional[QWidget] = None) -> None: + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setStyleSheet(load_css("button.css")) diff --git a/client/securedrop_client/gui/base/misc.py b/client/securedrop_client/gui/base/misc.py index 6ea6f1a2f..a459c1e5c 100644 --- a/client/securedrop_client/gui/base/misc.py +++ b/client/securedrop_client/gui/base/misc.py @@ -17,8 +17,6 @@ along with this program. If not, see . """ -from typing import Optional, Union - from PyQt5.QtCore import QSize, Qt from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget @@ -41,7 +39,7 @@ class SvgToggleButton(QPushButton): The display size of the SVG, defaults to filling the entire size of the widget. """ - def __init__(self, on: str, off: str, svg_size: Optional[QSize] = None): + def __init__(self, on: str, off: str, svg_size: QSize | None = None): super().__init__() # Set layout @@ -87,10 +85,10 @@ class SvgPushButton(QPushButton): def __init__( self, normal: str, - disabled: Optional[str] = None, - active: Optional[str] = None, - selected: Optional[str] = None, - svg_size: Optional[QSize] = None, + disabled: str | None = None, + active: str | None = None, + selected: str | None = None, + svg_size: QSize | None = None, ) -> None: super().__init__() @@ -126,7 +124,7 @@ class SvgLabel(QLabel): The display size of the SVG, defaults to filling the entire size of the widget. """ - def __init__(self, filename: str, svg_size: Optional[QSize] = None) -> None: + def __init__(self, filename: str, svg_size: QSize | None = None) -> None: super().__init__() # Remove margins and spacing @@ -140,7 +138,7 @@ def __init__(self, filename: str, svg_size: Optional[QSize] = None) -> None: self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize()) layout.addWidget(self.svg) - def update_image(self, filename: str, svg_size: Optional[QSize] = None) -> None: + def update_image(self, filename: str, svg_size: QSize | None = None) -> None: self.svg = load_svg(filename) self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize()) child = self.layout().takeAt(0) @@ -155,8 +153,8 @@ class SecureQLabel(QLabel): def __init__( self, text: str = "", - parent: Optional[QWidget] = None, - flags: Union[Qt.WindowFlags, Qt.WindowType] = Qt.WindowFlags(), + parent: QWidget | None = None, + flags: Qt.WindowFlags | Qt.WindowType = Qt.WindowFlags(), wordwrap: bool = True, max_length: int = 0, with_tooltip: bool = False, diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index 56b284393..c332d91b5 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -1,6 +1,5 @@ import logging from gettext import gettext as _ -from typing import Optional from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QIcon, QKeyEvent @@ -48,7 +47,7 @@ def __init__( export: Export, summary_text: str, filepaths: list[str], - parent: Optional[QWidget] = None, + parent: QWidget | None = None, ) -> None: # Normally, the active window is the right parent, but if the wizard is launched # via another element (a modal dialog, such as the "Some files may not be exported" @@ -62,7 +61,7 @@ def __init__( summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() self.filepaths = filepaths - self.current_status: Optional[ExportStatus] = None + self.current_status: ExportStatus | None = None # Signal from qrexec command runner self.export.export_state_changed.connect(self.on_status_received) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index f8a5db589..9ff90fdcf 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -1,6 +1,5 @@ import logging from gettext import gettext as _ -from typing import Optional from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QColor, QFont, QPixmap @@ -61,12 +60,12 @@ class ExportWizardPage(QWizardPage): ExportStatus.UNEXPECTED_RETURN_STATUS, ] - def __init__(self, export: Export, header: str, body: Optional[str]) -> None: + def __init__(self, export: Export, header: str, body: str | None) -> None: super().__init__() self.export = export self.header_text = header self.body_text = body - self.status: Optional[ExportStatus] = None + self.status: ExportStatus | None = None self._is_complete = True # Won't override parent method unless explicitly set to False self.setLayout(self._build_layout()) diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index 2e563c315..afe7661bd 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -1,5 +1,4 @@ from gettext import gettext as _ -from typing import Optional from PyQt5.QtCore import QSize, pyqtSlot @@ -21,7 +20,7 @@ def __init__(self, device: Export, file_name: str, filepaths: list[str]) -> None file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() # Hold onto the error status we receive from the Export VM - self.error_status: Optional[ExportStatus] = None + self.error_status: ExportStatus | None = None # Connect device signals to slots self._device.print_preflight_check_succeeded.connect( diff --git a/client/securedrop_client/gui/main.py b/client/securedrop_client/gui/main.py index d702d5e04..537aea87e 100644 --- a/client/securedrop_client/gui/main.py +++ b/client/securedrop_client/gui/main.py @@ -20,7 +20,6 @@ import logging from gettext import gettext as _ -from typing import Optional from PyQt5.QtCore import Qt from PyQt5.QtGui import QClipboard, QGuiApplication, QIcon, QKeySequence @@ -46,7 +45,7 @@ class Window(QMainWindow): def __init__( self, - app_state: Optional[state.State] = None, + app_state: state.State | None = None, ) -> None: """ Create the default start state. The window contains a root widget into @@ -89,7 +88,7 @@ def __init__( central_widget_layout.addWidget(self.main_pane) # Dialogs - self.login_dialog: Optional[LoginDialog] = None + self.login_dialog: LoginDialog | None = None # Actions quit = QAction(_("Quit"), self) @@ -109,7 +108,7 @@ def setup(self, controller: Controller) -> None: self.main_view.setup(self.controller) self.show_login() - def show_main_window(self, db_user: Optional[User] = None) -> None: + def show_main_window(self, db_user: User | None = None) -> None: """ Show main application window. """ diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index e3f21512f..52a016555 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -2800,7 +2800,7 @@ def update_conversation(self, collection: list) -> None: if item_widget: # FIXME: Item types cannot be defines as (FileWidget, MessageWidget, ReplyWidget) # because one test mocks MessageWidget. - assert isinstance(item_widget, (FileWidget, SpeechBubble)) + assert isinstance(item_widget, FileWidget | SpeechBubble) current_conversation.pop(conversation_item.uuid) if item_widget.index != index: # The existing widget is out of order. @@ -2831,7 +2831,7 @@ def update_conversation(self, collection: list) -> None: item_widget.sender = conversation_item.journalist elif isinstance(conversation_item, Message): self.add_message(conversation_item, index) - elif isinstance(conversation_item, (DraftReply, Reply)): + elif isinstance(conversation_item, DraftReply | Reply): self.add_reply(conversation_item, conversation_item.journalist, index) else: self.add_file(conversation_item, index) diff --git a/client/securedrop_client/logic.py b/client/securedrop_client/logic.py index 03c43a527..34cec4246 100644 --- a/client/securedrop_client/logic.py +++ b/client/securedrop_client/logic.py @@ -25,7 +25,6 @@ from datetime import datetime from gettext import gettext as _ from gettext import ngettext -from typing import Optional, Union import arrow import sqlalchemy.orm.exc @@ -120,7 +119,7 @@ def call_api(self) -> None: try: self.result = self.api_call(*self.args, **self.kwargs) except Exception as ex: - if isinstance(ex, (RequestTimeoutError, ServerConnectionError)): + if isinstance(ex, RequestTimeoutError | ServerConnectionError): self.call_timed_out.emit() logger.error("Call failed") @@ -320,9 +319,9 @@ def __init__( # type: ignore[no-untyped-def] state: state.State, proxy: bool = True, qubes: bool = True, - sync_thread: Optional[QThread] = None, - main_queue_thread: Optional[QThread] = None, - file_download_queue_thread: Optional[QThread] = None, + sync_thread: QThread | None = None, + main_queue_thread: QThread | None = None, + file_download_queue_thread: QThread | None = None, ) -> None: """ The hostname, gui and session objects are used to coordinate with the @@ -369,10 +368,10 @@ def __init__( # type: ignore[no-untyped-def] self.gui = gui # Reference to the API for secure drop proxy. - self.api: Optional[sdk.API] = None + self.api: sdk.API | None = None # Store authenticated user - self.authenticated_user: Union[db.User, None] = None + self.authenticated_user: db.User | None = None # Reference to the SqlAlchemy `sessionmaker` and `session` self.session_maker = session_maker @@ -580,7 +579,7 @@ def on_authenticate_failure(self, result: Exception) -> None: # Failed to authenticate. Reset state with failure message. self.invalidate_token() - if isinstance(result, (RequestTimeoutError, ServerConnectionError)): + if isinstance(result, RequestTimeoutError | ServerConnectionError): error = _( "Could not reach the SecureDrop server. Please check your \n" "Internet and Tor connection and try again." @@ -693,7 +692,7 @@ def on_sync_failure(self, result: Exception) -> None: return self._close_client_session() - elif isinstance(result, (RequestTimeoutError, ServerConnectionError)): + elif isinstance(result, RequestTimeoutError | ServerConnectionError): self.gui.update_error_status( _("The SecureDrop server cannot be reached. Trying to reconnect..."), duration=0 ) @@ -770,9 +769,7 @@ def on_seen_failure(self, error: Exception) -> None: def on_update_star_success(self, source_uuid: str) -> None: self.star_update_successful.emit(source_uuid) - def on_update_star_failure( - self, error: Union[UpdateStarJobError, UpdateStarJobTimeoutError] - ) -> None: + def on_update_star_failure(self, error: UpdateStarJobError | UpdateStarJobTimeoutError) -> None: if isinstance(error, UpdateStarJobError): self.gui.update_error_status(_("Failed to update star.")) source = self.session.query(db.Source).filter_by(uuid=error.source_uuid).one() @@ -822,10 +819,12 @@ def set_status(self, message: str, duration: int = 5000) -> None: @login_required def _submit_download_job( - self, object_type: Union[type[db.Reply], type[db.Message], type[db.File]], uuid: str + self, object_type: type[db.Reply] | type[db.Message] | type[db.File], uuid: str ) -> None: if object_type == db.Reply: - job = ReplyDownloadJob(uuid, self.data_dir, self.gpg) # type: Union[ReplyDownloadJob, MessageDownloadJob, FileDownloadJob] + job: ReplyDownloadJob | MessageDownloadJob | FileDownloadJob = ReplyDownloadJob( + uuid, self.data_dir, self.gpg + ) job.success_signal.connect(self.on_reply_download_success) job.failure_signal.connect(self.on_reply_download_failure) elif object_type == db.Message: @@ -952,7 +951,7 @@ def on_file_open(self, file: db.File) -> None: @login_required def on_submission_download( - self, submission_type: Union[type[db.File], type[db.Message]], submission_uuid: str + self, submission_type: type[db.File] | type[db.Message], submission_uuid: str ) -> None: """ Download the file associated with the Submission (which may be a File or Message). @@ -1117,9 +1116,7 @@ def on_reply_success(self, reply_uuid: str) -> None: reply = storage.get_reply(self.session, reply_uuid) self.reply_succeeded.emit(reply.source.uuid, reply_uuid, reply.content) - def on_reply_failure( - self, exception: Union[SendReplyJobError, SendReplyJobTimeoutError] - ) -> None: + def on_reply_failure(self, exception: SendReplyJobError | SendReplyJobTimeoutError) -> None: logger.debug(f"{exception.reply_uuid} failed to send") # only emit failure signal for non-timeout errors diff --git a/client/securedrop_client/queue.py b/client/securedrop_client/queue.py index 7365f1ea1..2d1db9eb6 100644 --- a/client/securedrop_client/queue.py +++ b/client/securedrop_client/queue.py @@ -2,7 +2,7 @@ import logging import threading from queue import PriorityQueue -from typing import Any, Optional +from typing import Any from PyQt5.QtCore import QObject, QThread, pyqtBoundSignal, pyqtSignal, pyqtSlot from sqlalchemy.orm import scoped_session @@ -36,7 +36,7 @@ class RunnablePriorityQueue(PriorityQueue): """ def __init__( - self, *args: Any, queue_updated_signal: Optional[pyqtBoundSignal] = None, **kwargs: Any + self, *args: Any, queue_updated_signal: pyqtBoundSignal | None = None, **kwargs: Any ): self.queue_updated_signal = queue_updated_signal super().__init__(*args, **kwargs) @@ -109,9 +109,9 @@ class RunnableQueue(QObject): def __init__( self, - api_client: Optional[API], + api_client: API | None, session_maker: scoped_session, - queue_updated_signal: Optional[pyqtBoundSignal] = None, + queue_updated_signal: pyqtBoundSignal | None = None, ) -> None: super().__init__() self.api_client = api_client @@ -121,7 +121,7 @@ def __init__( # needed because PriorityQueue is implemented using heapq which does not have sort # stability. For more info, see : https://bugs.python.org/issue17794 self.order_number = itertools.count() - self.current_job = None # type: Optional[QueueJob] + self.current_job: QueueJob | None = None # Hold when reading/writing self.current_job or mutating queue state self.condition_add_or_remove_job = threading.Condition() @@ -271,7 +271,7 @@ class ApiJobQueue(QObject): def __init__( self, - api_client: Optional[API], + api_client: API | None, session_maker: scoped_session, main_thread: QThread, download_file_thread: QThread, diff --git a/client/securedrop_client/resources/__init__.py b/client/securedrop_client/resources/__init__.py index ad19da4ec..a4e7a91b0 100644 --- a/client/securedrop_client/resources/__init__.py +++ b/client/securedrop_client/resources/__init__.py @@ -19,7 +19,6 @@ """ from pathlib import Path -from typing import Optional from PyQt5.QtCore import QDir from PyQt5.QtGui import QFontDatabase, QIcon, QMovie, QPixmap @@ -49,13 +48,13 @@ def load_all_fonts() -> None: def load_icon( normal: str, - disabled: Optional[str] = None, - active: Optional[str] = None, - selected: Optional[str] = None, - normal_off: Optional[str] = None, - disabled_off: Optional[str] = None, - active_off: Optional[str] = None, - selected_off: Optional[str] = None, + disabled: str | None = None, + active: str | None = None, + selected: str | None = None, + normal_off: str | None = None, + disabled_off: str | None = None, + active_off: str | None = None, + selected_off: str | None = None, ) -> QIcon: """ Add the contents of Scalable Vector Graphics (SVG) files provided for associated icon modes and diff --git a/client/securedrop_client/sdk/__init__.py b/client/securedrop_client/sdk/__init__.py index 10e441246..8a201862e 100644 --- a/client/securedrop_client/sdk/__init__.py +++ b/client/securedrop_client/sdk/__init__.py @@ -4,7 +4,7 @@ import os from datetime import datetime from subprocess import PIPE, Popen, TimeoutExpired -from typing import Any, Optional +from typing import Any from urllib.parse import urljoin import requests @@ -45,7 +45,7 @@ def __init__(self) -> None: super().__init__("Cannot connect to the server.") -def json_query(proxy_vm_name: str, data: str, timeout: Optional[int] = None) -> str: +def json_query(proxy_vm_name: str, data: str, timeout: int | None = None) -> str: """ Takes a JSON based query and passes it to the network proxy. Returns the JSON output from the proxy. @@ -93,8 +93,8 @@ def __init__( passphrase: str, totp: str, proxy: bool = False, - default_request_timeout: Optional[int] = None, - default_download_timeout: Optional[int] = None, + default_request_timeout: int | None = None, + default_download_timeout: int | None = None, ) -> None: """ Primary API class, this is the only thing which will make network call. @@ -103,13 +103,13 @@ def __init__( self.username = username self.passphrase = passphrase self.totp = totp - self.token = None # type: Optional[str] - self.token_expiration: Optional[datetime] = None - self.token_journalist_uuid = None # type: Optional[str] - self.first_name = None # type: Optional[str] - self.last_name = None # type: Optional[str] - self.req_headers = dict() # type: dict[str, str] - self.proxy = proxy # type: bool + self.token: str | None = None + self.token_expiration: datetime | None = None + self.token_journalist_uuid: str | None = None + self.first_name: str | None = None + self.last_name: str | None = None + self.req_headers: dict[str, str] = dict() + self.proxy: bool = proxy self.default_request_timeout = default_request_timeout or DEFAULT_REQUEST_TIMEOUT self.default_download_timeout = default_download_timeout or DEFAULT_DOWNLOAD_TIMEOUT @@ -126,9 +126,9 @@ def _send_json_request( self, method: str, path_query: str, - body: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - timeout: Optional[int] = None, + body: str | None = None, + headers: dict[str, str] | None = None, + timeout: int | None = None, ) -> tuple[Any, int, dict[str, str]]: if self.proxy: # We are using the Qubes securedrop-proxy func = self._send_rpc_json_request @@ -141,9 +141,9 @@ def _send_http_json_request( self, method: str, path_query: str, - body: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - timeout: Optional[int] = None, + body: str | None = None, + headers: dict[str, str] | None = None, + timeout: int | None = None, ) -> tuple[Any, int, dict[str, str]]: url = urljoin(self.server, path_query) kwargs = {"headers": headers} # type: dict[str, Any] @@ -174,9 +174,9 @@ def _send_rpc_json_request( self, method: str, path_query: str, - body: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - timeout: Optional[int] = None, + body: str | None = None, + headers: dict[str, str] | None = None, + timeout: int | None = None, ) -> tuple[Any, int, dict[str, str]]: data = {"method": method, "path_query": path_query} # type: dict[str, Any] @@ -219,7 +219,7 @@ def _send_rpc_json_request( return data, result["status"], result["headers"] - def authenticate(self, totp: Optional[str] = None) -> bool: + def authenticate(self, totp: str | None = None) -> bool: """ Authenticates the user and fetches the token from the server. @@ -572,7 +572,7 @@ def delete_submission_from_string(self, uuid: str, source_uuid: str) -> bool: return self.delete_submission(s) def download_submission( - self, submission: Submission, path: str = "", timeout: Optional[int] = None + self, submission: Submission, path: str = "", timeout: int | None = None ) -> tuple[str, str]: """ Returns a tuple of etag (format is algorithm:checksum) and file path for @@ -689,7 +689,7 @@ def get_users(self) -> list[User]: return result - def reply_source(self, source: Source, msg: str, reply_uuid: Optional[str] = None) -> Reply: + def reply_source(self, source: Source, msg: str, reply_uuid: str | None = None) -> Reply: """ This method is used to reply to a given source. The message should be preencrypted with the source's GPG public key. diff --git a/client/securedrop_client/sdk/sdlocalobjects.py b/client/securedrop_client/sdk/sdlocalobjects.py index bdeb73217..64a5e5a6a 100644 --- a/client/securedrop_client/sdk/sdlocalobjects.py +++ b/client/securedrop_client/sdk/sdlocalobjects.py @@ -1,10 +1,7 @@ -from typing import Optional - - class BaseError(Exception): """For generic errors not covered by other exceptions""" - def __init__(self, message: Optional[str] = None) -> None: + def __init__(self, message: str | None = None) -> None: self.msg = message def __str__(self) -> str: diff --git a/client/securedrop_client/sdk/timestamps.py b/client/securedrop_client/sdk/timestamps.py index 8a53a1a28..7a67d0976 100644 --- a/client/securedrop_client/sdk/timestamps.py +++ b/client/securedrop_client/sdk/timestamps.py @@ -1,8 +1,7 @@ from datetime import datetime -from typing import Optional -def parse(date_string: str) -> Optional[datetime]: +def parse(date_string: str) -> datetime | None: """Parse date strings in a few historical formats.""" try: # ISO8061 and RFC3339 diff --git a/client/securedrop_client/state/state.py b/client/securedrop_client/state/state.py index 64fd3e4ef..c75799293 100644 --- a/client/securedrop_client/state/state.py +++ b/client/securedrop_client/state/state.py @@ -6,8 +6,6 @@ Note: the Graphical User Interface MUST NOT write state, except in QActions. """ -from typing import Optional - from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot from securedrop_client.database import Database @@ -23,11 +21,11 @@ class State(QObject): selected_conversation_files_changed = pyqtSignal() - def __init__(self, database: Optional[Database] = None) -> None: + def __init__(self, database: Database | None = None) -> None: super().__init__() self._files: dict[FileId, File] = {} self._conversation_files: dict[ConversationId, list[File]] = {} - self._selected_conversation: Optional[ConversationId] = None + self._selected_conversation: ConversationId | None = None if database is not None: self._initialize_from_database(database) @@ -69,7 +67,7 @@ def conversation_files(self, id: ConversationId) -> list[File]: default: list[File] = [] return self._conversation_files.get(id, default) - def file(self, id: FileId) -> Optional[File]: + def file(self, id: FileId) -> File | None: return self._files.get(id, None) def record_file_download(self, id: FileId) -> None: @@ -80,12 +78,12 @@ def record_file_download(self, id: FileId) -> None: self.selected_conversation_files_changed.emit() @property - def selected_conversation(self) -> Optional[ConversationId]: + def selected_conversation(self) -> ConversationId | None: """The identifier of the currently selected conversation, or None""" return self._selected_conversation @selected_conversation.setter - def selected_conversation(self, id: Optional[ConversationId]) -> None: + def selected_conversation(self, id: ConversationId | None) -> None: self._selected_conversation = id self.selected_conversation_files_changed.emit() diff --git a/client/securedrop_client/storage.py b/client/securedrop_client/storage.py index d6215459d..ffb2d9526 100644 --- a/client/securedrop_client/storage.py +++ b/client/securedrop_client/storage.py @@ -26,7 +26,7 @@ import shutil from datetime import datetime from pathlib import Path -from typing import Any, Optional, TypeVar, Union +from typing import Any, TypeVar from dateutil.parser import parse from sqlalchemy import and_, desc, or_ @@ -418,9 +418,9 @@ def update_messages( def __update_submissions( - model: Union[type[File], type[Message]], + model: type[File] | type[Message], remote_submissions: list[SDKSubmission], - local_submissions: Union[list[Message], list[File]], + local_submissions: list[Message] | list[File], skip_uuids_deleted_conversation: list[str], skip_uuids_deleted_source: list[str], session: Session, @@ -866,7 +866,7 @@ def mark_as_not_downloaded(uuid: str, session: Session) -> None: def mark_as_downloaded( - model_type: Union[type[File], type[Message], type[Reply]], uuid: str, session: Session + model_type: type[File] | type[Message] | type[Reply], uuid: str, session: Session ) -> None: """ Mark object as downloaded in the database. @@ -889,11 +889,11 @@ def update_file_size(uuid: str, path: str, session: Session) -> None: def mark_as_decrypted( - model_type: Union[type[File], type[Message], type[Reply]], + model_type: type[File] | type[Message] | type[Reply], uuid: str, session: Session, is_decrypted: bool = True, - original_filename: Optional[str] = None, + original_filename: str | None = None, ) -> None: """ Mark object as downloaded in the database. @@ -909,7 +909,7 @@ def mark_as_decrypted( def set_message_or_reply_content( - model_type: Union[type[Message], type[Reply]], uuid: str, content: str, session: Session + model_type: type[Message] | type[Reply], uuid: str, content: str, session: Session ) -> None: """ Mark whether or not the object is decrypted. If it's not decrypted, do not set content. If the @@ -931,7 +931,7 @@ def delete_source_collection(journalist_filename: str, data_dir: str) -> None: def delete_single_submission_or_reply_on_disk( - obj_db: Union[File, Message, Reply], data_dir: str + obj_db: File | Message | Reply, data_dir: str ) -> None: """ Delete on disk any files associated with a single submission or reply. diff --git a/client/securedrop_client/sync.py b/client/securedrop_client/sync.py index 5587bd623..668d2ac03 100644 --- a/client/securedrop_client/sync.py +++ b/client/securedrop_client/sync.py @@ -1,5 +1,4 @@ import logging -from typing import Optional from PyQt5.QtCore import QObject, QThread, QTimer, pyqtBoundSignal, pyqtSignal from sqlalchemy.orm import scoped_session @@ -26,12 +25,12 @@ class ApiSync(QObject): def __init__( self, - api_client: Optional[API], + api_client: API | None, session_maker: scoped_session, gpg: GpgHelper, data_dir: str, sync_thread: QThread, - app_state: Optional[state.State] = None, + app_state: state.State | None = None, ): super().__init__() self.api_client = api_client @@ -106,14 +105,14 @@ class ApiSyncBackgroundTask(QObject): def __init__( # type: ignore[no-untyped-def] self, - api_client: Optional[API], + api_client: API | None, session_maker: scoped_session, gpg: GpgHelper, data_dir: str, sync_started: pyqtBoundSignal, on_sync_success, on_sync_failure, - app_state: Optional[state.State] = None, + app_state: state.State | None = None, ): super().__init__() diff --git a/client/securedrop_client/utils.py b/client/securedrop_client/utils.py index 7c29965e4..ea3b479c5 100644 --- a/client/securedrop_client/utils.py +++ b/client/securedrop_client/utils.py @@ -7,7 +7,7 @@ from collections.abc import Generator from contextlib import contextmanager from pathlib import Path -from typing import BinaryIO, Optional, Union +from typing import BinaryIO from sqlalchemy.orm.session import Session @@ -15,8 +15,8 @@ def safe_mkdir( - base_path: Union[Path, str], - relative_path: Optional[Union[Optional[Path], Optional[str]]] = None, + base_path: Path | str, + relative_path: Path | None | str = None, ) -> None: """ Safely create directories with restricted 700 permissions inside the base_path directory. The @@ -114,7 +114,7 @@ def safe_copyfileobj(src_file: gzip.GzipFile, dest_file: BinaryIO, dest_base_pat Path(dest_file.name).chmod(0o600) -def relative_filepath(filepath: Union[str, Path], base_dir: Union[str, Path]) -> Path: +def relative_filepath(filepath: str | Path, base_dir: str | Path) -> Path: """ Raise ValueError if the filepath is not relative to the supplied base_dir or if base_dir is not an absolute path. @@ -126,7 +126,7 @@ def relative_filepath(filepath: Union[str, Path], base_dir: Union[str, Path]) -> return Path(filepath).resolve().relative_to(base_dir) -def check_path_traversal(filename_or_filepath: Union[str, Path]) -> None: +def check_path_traversal(filename_or_filepath: str | Path) -> None: """ Raise ValueError if filename_or_filepath does any path traversal. This works on filenames, relative paths, and absolute paths. @@ -151,7 +151,7 @@ def check_path_traversal(filename_or_filepath: Union[str, Path]) -> None: raise ValueError(f"Unsafe file or directory name: '{filename_or_filepath}'") -def check_all_permissions(path: Union[str, Path], base_path: Union[str, Path]) -> None: +def check_all_permissions(path: str | Path, base_path: str | Path) -> None: """ Check that the permissions of each directory between base_path and path are set to 700. """ @@ -170,7 +170,7 @@ def check_all_permissions(path: Union[str, Path], base_path: Union[str, Path]) - check_dir_permissions(str(full_path)) -def check_dir_permissions(dir_path: Union[str, Path]) -> None: +def check_dir_permissions(dir_path: str | Path) -> None: """ Check that a directory has ``700`` as the final 3 bytes. Raises a ``RuntimeError`` otherwise. """ @@ -217,7 +217,7 @@ def __init__(self, session: Session) -> None: self.cache: dict[str, db.Source] = {} self.session = session - def get(self, source_uuid: str) -> Optional[db.Source]: + def get(self, source_uuid: str) -> db.Source | None: if source_uuid not in self.cache: source = self.session.query(db.Source).filter_by(uuid=source_uuid).first() self.cache[source_uuid] = source diff --git a/client/tests/gui/test_datetime_helpers.py b/client/tests/gui/test_datetime_helpers.py index d601e7980..6e2ddea38 100644 --- a/client/tests/gui/test_datetime_helpers.py +++ b/client/tests/gui/test_datetime_helpers.py @@ -13,7 +13,7 @@ def test_format_datetime_month_day(): # Dates are shown in the source list as well as the conversation view. Changing the date format # may result in UI issues - this test is a reminder to check both views! - midnight_january_london = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + midnight_january_london = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.UTC) assert format_datetime_month_day(midnight_january_london) == "Jan 1" @@ -23,7 +23,7 @@ def test_localise_datetime(mocker): "securedrop_client.gui.datetime_helpers.QTimeZone.systemTimeZoneId", return_value=QByteArray(b"Pacific/Auckland"), ) - evening_january_1_london = datetime.datetime(2023, 1, 1, 18, 0, 0, tzinfo=datetime.timezone.utc) + evening_january_1_london = datetime.datetime(2023, 1, 1, 18, 0, 0, tzinfo=datetime.UTC) morning_january_2_auckland = datetime.datetime( 2023, 1, 2, 7, 0, 0, tzinfo=tz.gettz("Pacific/Auckland") ) @@ -35,5 +35,5 @@ def test_format_datetime_local(mocker): "securedrop_client.gui.datetime_helpers.QTimeZone.systemTimeZoneId", return_value=QByteArray(b"Pacific/Auckland"), ) - evening_january_1_london = datetime.datetime(2023, 1, 1, 18, 0, 0, tzinfo=datetime.timezone.utc) + evening_january_1_london = datetime.datetime(2023, 1, 1, 18, 0, 0, tzinfo=datetime.UTC) assert format_datetime_local(evening_january_1_london) == "Jan 2" diff --git a/client/tests/sdk/test_shared.py b/client/tests/sdk/test_shared.py index 7bdb49efc..ac6a04de7 100644 --- a/client/tests/sdk/test_shared.py +++ b/client/tests/sdk/test_shared.py @@ -21,8 +21,8 @@ def api_auth(self): assert isinstance(self.api.token, str) assert isinstance(self.api.token_expiration, datetime.datetime) assert isinstance(self.api.token_journalist_uuid, str) - assert isinstance(self.api.first_name, (str, type(None))) - assert isinstance(self.api.last_name, (str, type(None))) + assert isinstance(self.api.first_name, str | None) + assert isinstance(self.api.last_name, str | None) # ---------------- SOURCES ---------------- diff --git a/client/tests/sdk/test_timestamps.py b/client/tests/sdk/test_timestamps.py index fd2657826..6117ce425 100644 --- a/client/tests/sdk/test_timestamps.py +++ b/client/tests/sdk/test_timestamps.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright © 2022 The Freedom of the Press Foundation. import unittest -from datetime import timedelta, timezone +from datetime import UTC, timedelta, timezone from securedrop_client.sdk.timestamps import parse as parse_datetime @@ -17,7 +17,7 @@ def test_parse_iso8061_returns_appropriate_time_zone_info(self): date_string = "2022-02-09T07:45:26.082728+00:00" dt = parse_datetime(date_string) - assert dt.tzinfo is timezone.utc + assert dt.tzinfo is UTC date_string = "2022-02-09T07:45:26.082728+02:00" dt = parse_datetime(date_string) @@ -29,11 +29,11 @@ def test_parse_iso8061_Z_returns_appropriate_time_zone_info(self): date_string = "2022-02-09T07:45:26.082728Z" dt = parse_datetime(date_string) - assert dt.tzinfo is timezone.utc + assert dt.tzinfo is UTC def test_parse_known_invalid_format_suceeds(self): date_string = "2022-02-09T07:45:26.082728+00:00Z" assert parse_datetime(date_string) is not None dt = parse_datetime(date_string) - assert dt.tzinfo is timezone.utc + assert dt.tzinfo is UTC diff --git a/client/tests/test_logic.py b/client/tests/test_logic.py index 3050328e7..538d6ae44 100644 --- a/client/tests/test_logic.py +++ b/client/tests/test_logic.py @@ -218,7 +218,7 @@ def test_Controller_on_authenticate_failure(homedir, config, mocker, session_mak co.api_sync.stop = mocker.MagicMock() co.on_authenticate_failure(exception) - if isinstance(exception, (RequestTimeoutError, ServerConnectionError)): + if isinstance(exception, RequestTimeoutError | ServerConnectionError): error = _( "Could not reach the SecureDrop server. Please check your \n" "Internet and Tor connection and try again." diff --git a/export/securedrop_export/directory.py b/export/securedrop_export/directory.py index adecc264a..6a8c1463b 100644 --- a/export/securedrop_export/directory.py +++ b/export/securedrop_export/directory.py @@ -1,12 +1,11 @@ import os import tarfile from pathlib import Path -from typing import Optional, Union def safe_mkdir( - base_path: Union[Path, str], - relative_path: Union[Optional[Path], Optional[str]] = None, + base_path: Path | str, + relative_path: Path | None | str = None, ) -> None: """ Safely create directories with restricted 700 permissions inside the base_path directory. The @@ -80,7 +79,7 @@ def safe_extractall(archive_file_path: str, dest_path: str) -> None: tar.extractall(dest_path) # noqa: S202 -def relative_filepath(filepath: Union[str, Path], base_dir: Union[str, Path]) -> Path: +def relative_filepath(filepath: str | Path, base_dir: str | Path) -> Path: """ Raise ValueError if the filepath is not relative to the supplied base_dir or if base_dir is not an absolute path. @@ -92,7 +91,7 @@ def relative_filepath(filepath: Union[str, Path], base_dir: Union[str, Path]) -> return Path(filepath).resolve().relative_to(base_dir) -def _check_path_traversal(filename_or_filepath: Union[str, Path]) -> None: +def _check_path_traversal(filename_or_filepath: str | Path) -> None: """ Raise ValueError if filename_or_filepath does any path traversal. This works on filenames, relative paths, and absolute paths. @@ -116,7 +115,7 @@ def _check_path_traversal(filename_or_filepath: Union[str, Path]) -> None: raise ValueError(f"Unsafe file or directory name: '{filename_or_filepath}'") -def _check_all_permissions(path: Union[str, Path], base_path: Union[str, Path]) -> None: +def _check_all_permissions(path: str | Path, base_path: str | Path) -> None: """ Check that the permissions of each directory between base_path and path are set to 700. """ @@ -135,7 +134,7 @@ def _check_all_permissions(path: Union[str, Path], base_path: Union[str, Path]) _check_dir_permissions(str(full_path)) -def _check_dir_permissions(dir_path: Union[str, Path]) -> None: +def _check_dir_permissions(dir_path: str | Path) -> None: """ Check that a directory has ``700`` as the final 3 bytes. Raises a ``RuntimeError`` otherwise. """ diff --git a/export/securedrop_export/disk/cli.py b/export/securedrop_export/disk/cli.py index 7f551a31c..8fe5c0421 100644 --- a/export/securedrop_export/disk/cli.py +++ b/export/securedrop_export/disk/cli.py @@ -5,7 +5,6 @@ import time from re import Pattern from shlex import quote -from typing import Optional, Union import pexpect @@ -27,20 +26,8 @@ # that includes regular expressions, byte or string patterns, *or* pexpect.EOF and pexpect.TIMEOUT, # but mypy needs a little help with it, so the below alias is used as a typehint. # See https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect -PexpectList = Union[ - Pattern[str], - Pattern[bytes], - str, - bytes, - type[pexpect.EOF], - type[pexpect.TIMEOUT], - list[ - Union[ - Pattern[str], - Pattern[bytes], - Union[str, bytes, Union[type[pexpect.EOF], type[pexpect.TIMEOUT]]], - ] - ], +PexpectList = list[ + Pattern[str] | Pattern[bytes] | str | bytes | type[pexpect.EOF] | type[pexpect.TIMEOUT] ] @@ -52,7 +39,7 @@ class CLI: CLI callers must handle ExportException. """ - def get_volume(self) -> Union[Volume, MountedVolume]: + def get_volume(self) -> Volume | MountedVolume: """ Search for valid connected device. Raise ExportException on error. @@ -146,7 +133,7 @@ def get_volume(self) -> Union[Volume, MountedVolume]: except subprocess.CalledProcessError as ex: raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - def _get_supported_volume(self, device: dict) -> Optional[Union[Volume, MountedVolume]]: + def _get_supported_volume(self, device: dict) -> Volume | MountedVolume | None: """ Given JSON-formatted lsblk output for one device, determine if it is suitably partitioned and return it to be used for export, @@ -262,12 +249,12 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: # pexpect allows for a match list that contains pexpect.EOF and pexpect.TIMEOUT # as well as string/regex matches: # https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect - prompt = [ + prompt: PexpectList = [ "Passphrase: ", pexpect.EOF, pexpect.TIMEOUT, - ] # type: PexpectList - expected = [ + ] + expected: PexpectList = [ f"Unlocked {volume.device_name} as (.*)[^\r\n].", "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Device " # string continues f"{volume.device_name} is already unlocked as (.*)[^\r\n].", @@ -275,7 +262,7 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: f"unlocking {volume.device_name}: Failed to activate device: Incorrect passphrase", pexpect.EOF, pexpect.TIMEOUT, - ] # type: PexpectList + ] unlock_error = Status.ERROR_UNLOCK_GENERIC child = pexpect.spawn(command, args) @@ -332,12 +319,12 @@ def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolum # The terminal output has colours and other formatting. A match is anything # that includes our device identified as PreferredDevice on one line # \x1b[37mPreferredDevice:\x1b[0m /dev/sdaX\r\n - expected_info = [ + expected_info: PexpectList = [ f"PreferredDevice:.*[^\r\n]{volume.device_name}", "Error looking up object for device", pexpect.EOF, pexpect.TIMEOUT, - ] # type: PexpectList + ] max_retries = 3 mount_cmd = "udisksctl" @@ -345,14 +332,14 @@ def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolum # We can't pass {full_unlocked_name} in the match statement since even if we # pass in /dev/mapper/xxx, udisks2 may refer to the disk as /dev/dm-X. - expected_mount = [ + expected_mount: PexpectList = [ "Mounted .* at (.*)", "Error mounting .*: GDBus.Error:org.freedesktop.UDisks2.Error.AlreadyMounted: " "Device (.*) is already mounted at `(.*)'.", "Error looking up object for device", pexpect.EOF, pexpect.TIMEOUT, - ] # type: PexpectList + ] mountpoint = None logger.debug(