Skip to content

Commit

Permalink
WIP: Stop using arrow/dateutil in favor of standard library
Browse files Browse the repository at this point in the history
The standard library's date formatting is better now (zoneinfo was added
in 3.9), so we don't need to rely on the arrow and dateutil libraries.

A few specifics:
* for the last sync file, just store a timestamp in ISO format. No back-
  compat is needed since reading is already fallible.
* the API's date format is fixed, so tweak it into ISO format and parse.
* to replace arrow's humanize, we can use babel's format_timedelta.

Note that this only actually removes arrow from our dependency tree as
dateutil is a dependency of alembic. It's already been removed upstream,
so once we upgrade alembic/SQLalchemy, it'll go away too.
  • Loading branch information
legoktm committed Oct 2, 2024
1 parent e540244 commit 00d1050
Show file tree
Hide file tree
Showing 13 changed files with 46 additions and 62 deletions.
1 change: 0 additions & 1 deletion client/build-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
alembic==1.1.0 --hash=sha256:de24f96f0ee198d4ebde1360e81d2590fa46ca3971f73e25a18b516917e8e178
arrow==0.12.1 --hash=sha256:1ebe2f35dc36d7150c00fa310d3a7e71616b48f2b0b522ad76b518fb5668174c
jinja2==3.1.3 --hash=sha256:a987f55fbbaebab55d75cf41ae415808855a40cf9d72ebf2dbe2aa00fd9243eb
mako==1.2.2 --hash=sha256:a891058241a8c119dfdb8c1e884d97910365bff24a364be850dbd0eb0248d0fa
markupsafe==2.0.1 --hash=sha256:465ea64f8d1af7349736132ab0f5521483551ae8814c0e655fca81f9b7c3f0ec --hash=sha256:9a055a175f351a559937fb80ebb2885d005283577a016c0139817e261fb759eb
Expand Down
29 changes: 2 additions & 27 deletions client/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ python = "^3.11"
Jinja2 = "3.1.3"
SQLAlchemy = "^1.3.3"
alembic = "^1.1.0"
arrow = "^0.12.1"
python-dateutil = "^2.7.5"

[tool.poetry.group.dev.dependencies]
# In production these two are installed using a system package
Expand All @@ -39,6 +37,5 @@ pytest-random-order = "*"
semgrep = "*"
translate-toolkit = "*"
types-polib = "*"
types-python-dateutil = "*"
types-setuptools = "^73.0.0"
vcrpy = "^6.0.1"
20 changes: 16 additions & 4 deletions client/securedrop_client/gui/datetime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,41 @@
"""

import datetime
from zoneinfo import ZoneInfo

import arrow
from dateutil import tz
from babel.dates import format_timedelta
from PyQt5.QtCore import QTimeZone


def format_datetime_month_day(date: datetime.datetime) -> str:
"""
Formats date as e.g. Sep 16
"""
return arrow.get(date).format("MMM D")
return date.strftime("%b %-d")


def localise_datetime(date: datetime.datetime) -> datetime.datetime:
"""
Localise the datetime object to system timezone
"""
local_timezone = QTimeZone.systemTimeZoneId().data().decode("utf-8")
return arrow.get(date).to(tz.gettz(local_timezone)).datetime
return date.replace(tzinfo=datetime.UTC).astimezone(ZoneInfo(local_timezone))


def format_datetime_local(date: datetime.datetime) -> str:
"""
Localise date and return as a string in the format e.g. Sep 16
"""
return format_datetime_month_day(localise_datetime(date))


def format_relative_time(dt: datetime.datetime) -> str:
"""
Returns a human-readable string representing the time difference
between the given datetime and now.
"""
now = datetime.datetime.now(datetime.UTC)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.UTC)

return format_timedelta(now - dt)
6 changes: 4 additions & 2 deletions client/securedrop_client/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"""

import logging
from datetime import datetime
from gettext import gettext as _

from PyQt5.QtCore import Qt
Expand All @@ -28,6 +29,7 @@
from securedrop_client import __version__, state
from securedrop_client.db import Source, User
from securedrop_client.gui.auth import LoginDialog
from securedrop_client.gui.datetime_helpers import format_relative_time
from securedrop_client.gui.widgets import BottomPane, LeftPane, MainView
from securedrop_client.logic import Controller
from securedrop_client.resources import load_all_fonts, load_css, load_icon
Expand Down Expand Up @@ -168,12 +170,12 @@ def show_sources(self, sources: list[Source]) -> None:
"""
self.main_view.show_sources(sources)

def show_last_sync(self, updated_on): # type: ignore[no-untyped-def]
def show_last_sync(self, updated_on: datetime) -> None:
"""
Display a message indicating the time of last sync with the server.
"""
if updated_on:
self.update_sync_status(_("Last Refresh: {}").format(updated_on.humanize()))
self.update_sync_status(_("Last Refresh: {}").format(format_relative_time(updated_on)))
else:
self.update_sync_status(_("Last Refresh: never"))

Expand Down
5 changes: 1 addition & 4 deletions client/securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from typing import Optional, Union
from uuid import uuid4

import arrow
import sqlalchemy.orm.exc
from PyQt5.QtCore import QEvent, QObject, QSize, Qt, QTimer, pyqtBoundSignal, pyqtSignal, pyqtSlot
from PyQt5.QtGui import (
Expand Down Expand Up @@ -869,9 +868,7 @@ def __lt__(self, other: SourceListWidgetItem) -> bool:
if me and them:
assert isinstance(me, SourceWidget)
assert isinstance(them, SourceWidget)
my_ts = arrow.get(me.last_updated)
other_ts = arrow.get(them.last_updated)
return my_ts < other_ts
return me.last_updated < them.last_updated
return True


Expand Down
9 changes: 4 additions & 5 deletions client/securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@
import logging
import os
import uuid
from datetime import datetime
from datetime import UTC, datetime
from gettext import gettext as _
from gettext import ngettext

import arrow
import sqlalchemy.orm.exc
from PyQt5.QtCore import QObject, QProcess, QThread, QTimer, pyqtSignal, pyqtSlot
from sqlalchemy.orm.session import sessionmaker
Expand Down Expand Up @@ -623,13 +622,13 @@ def authenticated(self) -> bool:
"""
return bool(self.api and self.api.token is not None)

def get_last_sync(self): # type: ignore[no-untyped-def]
def get_last_sync(self) -> datetime | None:
"""
Returns the time of last synchronisation with the remote SD server.
"""
try:
with open(self.last_sync_filepath) as f:
return arrow.get(f.read())
return datetime.fromisoformat(f.read())
except Exception:
return None

Expand All @@ -648,7 +647,7 @@ def on_sync_success(self) -> None:
successful
"""
with open(self.last_sync_filepath, "w") as f:
f.write(arrow.now().format())
f.write(datetime.now(UTC).isoformat())
self.show_last_sync()

missing_files = storage.update_missing_files(self.data_dir, self.session)
Expand Down
9 changes: 6 additions & 3 deletions client/securedrop_client/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
from pathlib import Path
from typing import Any, TypeVar

from dateutil.parser import parse
from sqlalchemy import and_, desc, or_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.exc import NoResultFound
Expand Down Expand Up @@ -332,7 +331,11 @@ def update_sources(
lazy_setattr(local_source, "is_flagged", source.is_flagged)
lazy_setattr(local_source, "interaction_count", source.interaction_count)
lazy_setattr(local_source, "is_starred", source.is_starred)
lazy_setattr(local_source, "last_updated", parse(source.last_updated))
lazy_setattr(
local_source,
"last_updated",
datetime.fromisoformat(source.last_updated.replace("Z", "+00:00")),
)
lazy_setattr(local_source, "public_key", source.key["public"])
lazy_setattr(local_source, "fingerprint", source.key["fingerprint"])

Expand Down Expand Up @@ -360,7 +363,7 @@ def update_sources(
is_flagged=source.is_flagged,
interaction_count=source.interaction_count,
is_starred=source.is_starred,
last_updated=parse(source.last_updated),
last_updated=datetime.fromisoformat(source.last_updated.replace("Z", "+00:00")),
document_count=source.number_of_documents,
public_key=source.key["public"],
fingerprint=source.key["fingerprint"],
Expand Down
2 changes: 1 addition & 1 deletion client/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
long_description=long_description,
long_description_content_type="text/markdown",
license="AGPLv3+",
install_requires=["SQLAlchemy", "alembic", "securedrop-sdk", "python-dateutil", "arrow"],
install_requires=["SQLAlchemy", "alembic", "securedrop-sdk"],
python_requires=">=3.5",
url="https://github.com/freedomofpress/securedrop-client",
packages=setuptools.find_packages(include=["securedrop_client", "securedrop_client.*"]),
Expand Down
4 changes: 2 additions & 2 deletions client/tests/gui/test_datetime_helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime
from zoneinfo import ZoneInfo

from dateutil import tz
from PyQt5.QtCore import QByteArray

from securedrop_client.gui.datetime_helpers import (
Expand All @@ -25,7 +25,7 @@ def test_localise_datetime(mocker):
)
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")
2023, 1, 2, 7, 0, 0, tzinfo=ZoneInfo("Pacific/Auckland")
)
assert localise_datetime(evening_january_1_london) == morning_january_2_auckland

Expand Down
7 changes: 4 additions & 3 deletions client/tests/gui/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import unittest
from datetime import UTC, datetime, timedelta

from PyQt5.QtWidgets import QHBoxLayout

Expand Down Expand Up @@ -326,13 +327,13 @@ def test_clear_error_status(mocker):

def test_show_last_sync(mocker):
"""
If there's a value display the result of its "humanize" method.humanize
If there's a value display the formatted version of the timestamp.
"""
w = Window()
w.update_sync_status = mocker.MagicMock()
updated_on = mocker.MagicMock()
updated_on = datetime.now(UTC) - timedelta(seconds=5)
w.show_last_sync(updated_on)
w.update_sync_status.assert_called_once_with(f"Last Refresh: {updated_on.humanize()}")
w.update_sync_status.assert_called_once_with("Last Refresh: 5 seconds")


def test_show_last_sync_no_sync(mocker):
Expand Down
9 changes: 4 additions & 5 deletions client/tests/test_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from gettext import gettext as _
from unittest.mock import Mock, call

import arrow
import pytest
import sqlalchemy.orm.exc
from PyQt5.QtTest import QSignalSpy
Expand Down Expand Up @@ -385,20 +384,20 @@ def test_Controller_last_sync_with_file(homedir, config, mocker, session_maker):
"""
The flag indicating the time of the last sync with the API is stored in a
dotfile in the user's home directory. If such a file exists, ensure an
"arrow" object (representing the date/time) is returned.
datetime object is returned.
Using the `config` fixture to ensure the config is written to disk.
"""
mock_gui = mocker.MagicMock()

co = Controller("http://localhost", mock_gui, session_maker, homedir, None)

timestamp = "2018-10-10 18:17:13+01:00"
timestamp = "2018-10-10T18:17:13.045250+00:00"
mocker.patch("builtins.open", mocker.mock_open(read_data=timestamp))

result = co.get_last_sync()

assert isinstance(result, arrow.Arrow)
assert result.format() == timestamp
assert isinstance(result, datetime.datetime)
assert result.isoformat() == timestamp


def test_Controller_last_sync_no_file(homedir, config, mocker, session_maker):
Expand Down
4 changes: 2 additions & 2 deletions client/tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from tempfile import TemporaryDirectory

import pytest
from dateutil.parser import parse
from PyQt5.QtCore import QThread
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.exc import NoResultFound
Expand Down Expand Up @@ -406,7 +405,8 @@ def _is_equivalent_source(source, remote_source) -> bool:
and source.fingerprint == remote_source.key["fingerprint"]
and source.interaction_count == remote_source.interaction_count
and source.is_starred == remote_source.is_starred
and source.last_updated == parse(remote_source.last_updated)
and source.last_updated
== datetime.datetime.fromisoformat(remote_source.last_updated.replace("Z", "+00:00"))
)


Expand Down

0 comments on commit 00d1050

Please sign in to comment.