From 5cf31bc6bdc5331311dafb22d38f68450ce0dfbc Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Thu, 6 Jul 2023 11:46:42 +0100 Subject: [PATCH 01/20] Undo change to allow Pillow v10 --- CHANGELOG.md | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34959f4..910f721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Allow usage of Pillow v10 +- None ## [3.1.0] - 2023-06-20 diff --git a/setup.py b/setup.py index 0fc7425..3b543ae 100644 --- a/setup.py +++ b/setup.py @@ -92,7 +92,7 @@ def get_author_email(): "django-sortedm2m>=3.0.0,<3.2", "django-taggit>=3.0.0,<5.0", "flickrapi>=2.4,<2.5", - "pillow>=8.0.0,<11.0", + "pillow>=8.0.0,<10.0", "twitter-text-python>=1.1.1,<1.2", "twython>=3.7.0,<3.10", ], From ce05b6083dc0f1c2c10051e2a885d89d5059609d Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 13:43:43 +0000 Subject: [PATCH 02/20] Switch from black and flake8 to ruff for linting and formatting ruff pre-commit hook omitted so that we can commit this before fixing everything --- .github/workflows/tests.yml | 25 ++++++++++-------------- .pre-commit-config.yaml | 32 +++++++++++++------------------ devproject/.python-version | 2 +- pyproject.toml | 38 ++++++++++++++++++++++++++++++++----- setup.cfg | 4 ---- setup.py | 5 ++--- tox.ini | 9 ++++----- 7 files changed, 63 insertions(+), 52 deletions(-) delete mode 100644 setup.cfg diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6f5695..11f3395 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,11 +1,10 @@ - name: Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -16,7 +15,6 @@ jobs: strategy: fail-fast: false - max-parallel: 5 matrix: python-version: ["3.9", "3.10", "3.11", "3.12-dev"] django-version: ["3.2", "4.1", "4.2", "main"] @@ -50,8 +48,7 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} - key: - ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} + key: ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v2- @@ -68,15 +65,14 @@ jobs: - name: Upload Coverage to Codecov uses: codecov/codecov-action@v3 - # The flake8 test in tox.ini won't run with the test job so we need to add it here. - lint: - name: "Lint: ${{ matrix.toxenv }}" + ruff: + name: "Run ruff: ${{ matrix.toxenv }}" runs-on: ubuntu-latest strategy: matrix: toxenv: - - flake8 + - ruff steps: - name: Git clone @@ -85,7 +81,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Get pip cache dir id: pip-cache @@ -95,8 +91,7 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} - key: - ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} + key: ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v2- @@ -112,11 +107,11 @@ jobs: # https://github.com/8398a7/action-slack/issues/72#issuecomment-649910353 name: Slack notification runs-on: ubuntu-latest - needs: [test, lint] + needs: [test, ruff] # this is required, otherwise it gets skipped if any needed jobs fail. # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idneeds - if: always() # Pick up events even if the job fails or is cancelled. + if: always() # Pick up events even if the job fails or is cancelled. steps: - uses: technote-space/workflow-conclusion-action@v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7b8b7f..cdf59c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.10 + python: python3.11 exclude: | (?x)^( @@ -13,29 +13,23 @@ exclude: | )$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.4.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - repo: https://github.com/psf/black - rev: 22.6.0 - hooks: - - id: black - - repo: https://github.com/pycqa/isort - rev: 5.10.1 - hooks: - - id: isort - - repo: https://github.com/pycqa/flake8 - rev: 4.0.1 - hooks: - - id: flake8 + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v3.0.3 hooks: - id: prettier types_or: - css - javascript - json + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py38-plus] diff --git a/devproject/.python-version b/devproject/.python-version index c84ccce..9ac3804 100644 --- a/devproject/.python-version +++ b/devproject/.python-version @@ -1 +1 @@ -3.10.5 +3.11.5 diff --git a/pyproject.toml b/pyproject.toml index c938118..4db14cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,3 @@ -[tool.black] -target-version = ["py310"] - [tool.coverage.report] omit = ["*/migrations/*"] @@ -9,5 +6,36 @@ branch = true include = ["ditto/*"] omit = ["*/migrations/*.py"] -[tool.isort] -profile = "black" +[tool.ruff] +extend-exclude = [ + "*/migrations/*", +] +line-length = 88 +select = [ + # Reference: https://docs.astral.sh/ruff/rules/ + "B", # flake8-bugbear + "E", # pycodestyle + "F", # Pyflakes + "G", # flake8-logging-format + "I", # isort + "N", # pip8-naming + "Q", # flake8-quotes + "BLE", # flake8-blind-except + "DJ", # flake8-django + "DTZ", # flake8-datetimez + "EM", # flake8-errmsg + "INP", # flake8-no-pep420 + "FBT", # flake8-boolean-trap + "PIE", # flake8-pie + "RSE", # flake-raise + "SIM", # flake8-simplify + "T20", # flake8-print + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "RUF100", # unused-noqa + "RUF200", # invalid-pyproject-toml +] +target-version = "py311" + +[tool.ruff.lint] +ignore = [] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index cba21f5..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -exclude = .git,__pycache__,.tox,build,dist -extend-ignore = E203, W503 -max-line-length = 88 diff --git a/setup.py b/setup.py index 3b543ae..ff8ed2c 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def get_author_email(): # Do `python setup.py tag` to tag with the current version number. if sys.argv[-1] == "tag": - os.system("git tag -a %s -m 'version %s'" % (get_version(), get_version())) + os.system(f"git tag -a {get_version()} -m 'version {get_version()}'") os.system("git push --tags") sys.exit() @@ -70,9 +70,8 @@ def get_author_email(): dev_require = [ "django-debug-toolbar>=2.0,<5.0", - "flake8>=4.0,<7.0", - "black", "python-dotenv", + "ruff", "unittest-parametrize", ] diff --git a/tox.ini b/tox.ini index 96d8d9f..09fc490 100644 --- a/tox.ini +++ b/tox.ini @@ -3,11 +3,11 @@ minversion = 1.8 envlist = + ruff py39-django{32,41,42,main} py310-django{32,41,42,main} py311-django{41,42,main} py312-django{42,main} - flake8 [gh-actions] ; Maps GitHub Actions python version numbers to tox env vars: @@ -50,8 +50,7 @@ commands = coverage run --branch --source=ditto --omit=*/migrations/*.py {envbindir}/django-admin test {posargs:} coverage report -m -[testenv:flake8] -basepython = python3 +[testenv:ruff] skip_install = true -deps = flake8 -commands = flake8 {posargs:ditto} +deps = ruff +commands = ruff check {posargs:--show-source .} From c6f1a7059f6e1755070ad60e9272bc50b03dc43c Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 18:33:55 +0000 Subject: [PATCH 03/20] Formatting etc to please ruff, our new master --- .pre-commit-config.yaml | 7 +- devproject/devproject/settings.py | 1 - devproject/requirements.txt | 2 +- ditto/core/apps.py | 3 +- ditto/core/management/commands/__init__.py | 8 +- ditto/core/models.py | 4 +- ditto/core/paginator.py | 1 + ditto/core/templatetags/ditto_core.py | 17 +- ditto/core/urls.py | 2 +- ditto/core/utils/__init__.py | 11 +- ditto/core/utils/downloader.py | 42 ++--- ditto/core/views.py | 53 +++--- ditto/flickr/admin.py | 11 +- ditto/flickr/checks.py | 15 +- ditto/flickr/factories.py | 3 +- ditto/flickr/fetch/fetchers.py | 132 +++++++------ ditto/flickr/fetch/filesfetchers.py | 17 +- ditto/flickr/fetch/multifetchers.py | 32 ++-- ditto/flickr/fetch/savers.py | 36 ++-- ditto/flickr/management/commands/__init__.py | 19 +- .../commands/fetch_flickr_account_user.py | 12 +- .../commands/fetch_flickr_originals.py | 5 +- .../commands/fetch_flickr_photos.py | 3 +- .../commands/fetch_flickr_photosets.py | 3 +- ditto/flickr/managers.py | 2 +- ditto/flickr/models.py | 107 +++++------ ditto/flickr/templatetags/ditto_flickr.py | 23 +-- ditto/flickr/views.py | 20 +- ditto/lastfm/admin.py | 3 +- ditto/lastfm/factories.py | 3 +- ditto/lastfm/fetch.py | 63 +++---- .../commands/fetch_lastfm_scrobbles.py | 8 +- ditto/lastfm/managers.py | 21 +-- ditto/lastfm/models.py | 62 +++--- ditto/lastfm/templatetags/ditto_lastfm.py | 26 ++- ditto/lastfm/urls.py | 6 +- ditto/lastfm/views.py | 41 ++-- ditto/pinboard/admin.py | 3 +- ditto/pinboard/checks.py | 15 +- ditto/pinboard/factories.py | 3 +- ditto/pinboard/fetch.py | 38 ++-- .../commands/fetch_pinboard_bookmarks.py | 14 +- ditto/pinboard/models.py | 15 +- ditto/pinboard/templatetags/ditto_pinboard.py | 10 +- ditto/pinboard/views.py | 14 +- ditto/scripts/flickr_authorize.py | 2 +- ditto/twitter/admin.py | 40 ++-- ditto/twitter/factories.py | 6 +- ditto/twitter/fetch/fetch.py | 49 ++--- ditto/twitter/fetch/fetchers.py | 26 +-- ditto/twitter/fetch/savers.py | 36 ++-- ditto/twitter/ingest.py | 75 ++++---- ditto/twitter/management/commands/__init__.py | 20 +- .../commands/fetch_twitter_accounts.py | 8 +- .../commands/fetch_twitter_favorites.py | 4 +- .../commands/fetch_twitter_files.py | 5 +- .../commands/fetch_twitter_tweets.py | 4 +- .../commands/generate_twitter_tweet_html.py | 7 +- .../commands/import_twitter_tweets.py | 29 ++- .../commands/update_twitter_tweets.py | 3 +- .../commands/update_twitter_users.py | 3 +- ditto/twitter/managers.py | 2 +- ditto/twitter/models.py | 177 ++++++++---------- ditto/twitter/templatetags/ditto_twitter.py | 14 +- ditto/twitter/utils.py | 6 +- ditto/twitter/views.py | 4 +- docs/conf.py | 7 +- setup.py | 6 +- tests/core/__init__.py | 2 +- tests/core/test_apps.py | 1 - tests/core/test_paginator.py | 1 - tests/core/test_templatetags.py | 4 +- tests/core/test_utils.py | 16 +- tests/flickr/test_fetch.py | 26 ++- tests/flickr/test_fetch_fetchers.py | 14 +- tests/flickr/test_fetch_filesfetchers.py | 12 +- tests/flickr/test_fetch_savers.py | 32 ++-- tests/flickr/test_management_commands.py | 46 ++--- tests/flickr/test_models.py | 51 ++--- tests/flickr/test_templatetags.py | 6 +- tests/flickr/test_views.py | 7 +- tests/lastfm/test_fetch.py | 13 +- tests/lastfm/test_utils.py | 2 +- tests/lastfm/test_views.py | 7 +- tests/pinboard/test_fetch.py | 55 +++--- tests/pinboard/test_management_commands.py | 1 - tests/pinboard/test_models.py | 7 +- tests/pinboard/test_templatetags.py | 5 +- tests/twitter/test_fetch.py | 24 ++- tests/twitter/test_fetch_fetchers.py | 48 ++--- tests/twitter/test_fetch_savers.py | 19 +- tests/twitter/test_ingest_v1.py | 21 +-- tests/twitter/test_ingest_v2.py | 2 +- tests/twitter/test_management_commands.py | 21 +-- tests/twitter/test_models.py | 31 ++- tests/twitter/test_templatetags.py | 6 +- tests/twitter/test_utils.py | 40 ++-- 97 files changed, 936 insertions(+), 1053 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cdf59c7..bfec2e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,12 @@ repos: - css - javascript - json - + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format - repo: https://github.com/asottile/pyupgrade rev: v3.15.0 hooks: diff --git a/devproject/devproject/settings.py b/devproject/devproject/settings.py index ae62c91..afbd6b1 100644 --- a/devproject/devproject/settings.py +++ b/devproject/devproject/settings.py @@ -13,7 +13,6 @@ from dotenv import load_dotenv - # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/devproject/requirements.txt b/devproject/requirements.txt index f55bf34..7b9cbb4 100644 --- a/devproject/requirements.txt +++ b/devproject/requirements.txt @@ -1 +1 @@ --e file:./..[dev] +-e file:./..[test] diff --git a/ditto/core/apps.py b/ditto/core/apps.py index 11288fa..82f119a 100644 --- a/ditto/core/apps.py +++ b/ditto/core/apps.py @@ -1,4 +1,3 @@ -# coding: utf-8 from django.apps import AppConfig, apps @@ -11,7 +10,7 @@ class DittoCoreConfig(AppConfig): default_auto_field = "django.db.models.AutoField" -class Apps(object): +class Apps: """Methods for seeing which Ditto apps are installed/enabled. At the moment installed is the same as enabled, but in future we may add conditions that mean an installed app can be disabled. diff --git a/ditto/core/management/commands/__init__.py b/ditto/core/management/commands/__init__.py index ea4dbc2..6280c90 100644 --- a/ditto/core/management/commands/__init__.py +++ b/ditto/core/management/commands/__init__.py @@ -15,7 +15,6 @@ class DittoBaseCommand(BaseCommand): def add_arguments(self, parser): "We may add stuff for handling verbosity here." - pass def output_results(self, results, verbosity=1): """results should be a list of dicts. @@ -46,12 +45,11 @@ def output_results(self, results, verbosity=1): noun = ( self.singular_noun if result["fetched"] == 1 else self.plural_noun ) - self.stdout.write("%sFetched %s %s" % (prefix, result["fetched"], noun)) + self.stdout.write(f"{prefix}Fetched {result['fetched']} {noun}") if result["success"] is False: self.stderr.write( - "%sFailed to fetch %s: %s" - % ( + "{}Failed to fetch {}: {}".format( prefix, self.plural_noun, self.format_messages(result["messages"]), @@ -63,4 +61,4 @@ def format_messages(self, messages): return messages[0] else: # On separate lines, and start a newline first. - return "%s%s" % ("\n", "\n".join(messages)) + return "{}{}".format("\n", "\n".join(messages)) diff --git a/ditto/core/models.py b/ditto/core/models.py index e2524d8..a54b2fb 100644 --- a/ditto/core/models.py +++ b/ditto/core/models.py @@ -1,4 +1,3 @@ -# coding: utf-8 from django.db import models from django.forms.models import model_to_dict @@ -8,6 +7,7 @@ class TimeStampedModelMixin(models.Model): "Should be mixed in to all models." + time_created = models.DateTimeField( auto_now_add=True, help_text="The time this item was created in the database." ) @@ -19,7 +19,7 @@ class Meta: abstract = True -class DiffModelMixin(object): +class DiffModelMixin: """A model mixin that tracks model fields' values and provide some useful api to know what fields have been changed. diff --git a/ditto/core/paginator.py b/ditto/core/paginator.py index 5b8b1f2..bf946c4 100644 --- a/ditto/core/paginator.py +++ b/ditto/core/paginator.py @@ -1,3 +1,4 @@ +# ruff: noqa import math from functools import reduce diff --git a/ditto/core/templatetags/ditto_core.py b/ditto/core/templatetags/ditto_core.py index 20f6134..5dcb49d 100644 --- a/ditto/core/templatetags/ditto_core.py +++ b/ditto/core/templatetags/ditto_core.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils.html import format_html -from .. import app_settings -from ..apps import ditto_apps +from ditto.core import app_settings +from ditto.core.apps import ditto_apps register = template.Library() @@ -64,11 +64,11 @@ def width_height(w, h, max_w, max_h): width = int(round(w * ratio)) height = int(round(h * ratio)) - return format_html('width="%s" height="%s"' % (width, height)) + return format_html('width="{}" height="{}"', width, height) @register.simple_tag -def display_time(dt, link_to_day=False, granularity=0, case=None): +def display_time(dt, *, link_to_day=False, granularity=0, case=None): """Return the HTML to display the time a Photo, Tweet, etc. dt -- The datetime. @@ -126,9 +126,7 @@ def display_time(dt, link_to_day=False, granularity=0, case=None): # Replace the [date] token with the date format wrapped in tag: dt_fmt = dt_fmt.replace( "[date]", - '{}'.format( - url, d_fmt - ), + f'{d_fmt}', ) else: dt_fmt = dt_fmt.replace("[date]", d_fmt) @@ -144,10 +142,7 @@ def display_time(dt, link_to_day=False, granularity=0, case=None): elif case == "capfirst": visible_time = visible_time[0].upper() + visible_time[1:] - return format_html( - '' - % {"stamp": stamp, "visible": visible_time} - ) + return format_html('', stamp, visible_time) @register.simple_tag(takes_context=True) diff --git a/ditto/core/urls.py b/ditto/core/urls.py index ee22474..3feee19 100644 --- a/ditto/core/urls.py +++ b/ditto/core/urls.py @@ -18,7 +18,7 @@ # ), re_path( # /2016/04/18/twitter/favorites - r"^(?P\d{4})/(?P\d{2})/(?P\d{2})(?:/(?P[a-z]+))?(?:/(?P[a-z\/]+|))?$", # noqa: E501 + r"^(?P\d{4})/(?P\d{2})/(?P\d{2})(?:/(?P[a-z]+))?(?:/(?P[a-z\/]+|))?$", view=views.DayArchiveView.as_view(), name="day_archive", ), diff --git a/ditto/core/utils/__init__.py b/ditto/core/utils/__init__.py index 727f8ca..0fec75f 100644 --- a/ditto/core/utils/__init__.py +++ b/ditto/core/utils/__init__.py @@ -1,5 +1,4 @@ -# coding: utf-8 -from datetime import datetime, timezone +from datetime import UTC, datetime from django.db.models import Count from django.utils.html import strip_tags @@ -7,7 +6,7 @@ def truncate_string( - text, strip_html=True, chars=255, truncate="…", at_word_boundary=False + text, *, strip_html=True, chars=255, truncate="…", at_word_boundary=False ): """Truncate a string to a certain length, removing line breaks and mutliple spaces, optionally removing HTML, and appending a 'truncate' string. @@ -35,14 +34,14 @@ def datetime_now(): """Just returns a datetime object for now in UTC, with UTC timezone. Because I was doing this a lot in various places. """ - return datetime.utcnow().replace(tzinfo=timezone.utc) + return datetime.now(tz=UTC) def datetime_from_str(s): """A shortcut for making a UTC datetime from a string like '2015-08-11 12:00:00'. """ - return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC) def get_annual_item_counts(qs, field_name="post_year"): @@ -86,7 +85,7 @@ def get_annual_item_counts(qs, field_name="post_year"): return [] # Make a set of years like {2015, 2016, 2018}: - years_with_counts = set(y[field_name] for y in qs) + years_with_counts = {y[field_name] for y in qs} # Make a set of years with no gaps like {2015, 2016, 2017, 2018}: all_years = sorted(set(range(min(years_with_counts), max(years_with_counts) + 1))) diff --git a/ditto/core/utils/downloader.py b/ditto/core/utils/downloader.py index edce0c1..ee168e2 100644 --- a/ditto/core/utils/downloader.py +++ b/ditto/core/utils/downloader.py @@ -1,3 +1,4 @@ +import contextlib import os import re import shutil @@ -5,11 +6,11 @@ import requests -class DownloadException(Exception): +class DownloadException(Exception): # noqa: N818 pass -class FileDownloader(object): +class FileDownloader: """ For downloading a file from a URL and saving it into /tmp/. @@ -42,7 +43,7 @@ def download(self, url, acceptable_content_types): if r.headers["Content-Type"] in acceptable_content_types: # Where we'll temporarily save the file: filename = self.make_filename(url, r.headers) - filepath = "%s%s" % (self.path, filename) + filepath = f"{self.path}{filename}" # Save the file there: with open(filepath, "wb") as f: r.raw.decode_content = True @@ -50,24 +51,21 @@ def download(self, url, acceptable_content_types): return filepath else: - raise DownloadException( - "Invalid content type (%s) when fetching %s" - % (r.headers["content_type"], url) + msg = "Invalid content type ({}) when fetching {}".format( + r.headers["content_type"], url ) - except KeyError: - raise DownloadException( - "No content_type headers found when fetching %s" % url - ) + raise DownloadException(msg) + except KeyError as err: + msg = f"No content_type headers found when fetching {url}" + raise DownloadException(msg) from err else: - raise DownloadException( - "Got status code %s when fetching %s" % (r.status_code, url) - ) - except requests.exceptions.RequestException as e: - raise DownloadException( - "Something when wrong when fetching %s: %s" % (url, e) - ) - - def make_filename(self, url, headers={}): + msg = f"Got status code {r.status_code} when fetching {url}" + raise DownloadException(msg) + except requests.exceptions.RequestException as err: + msg = f"Something when wrong when fetching {url}: {err}" + raise DownloadException(msg) from err + + def make_filename(self, url, headers=None): """ Find the filename of a downloaded file. Returns a string. @@ -80,6 +78,8 @@ def make_filename(self, url, headers={}): header. This is the case for Videos we download from Flickr. """ + headers = {} if headers is None else headers + # Should work for photos: filename = os.path.basename(url) @@ -90,10 +90,8 @@ def make_filename(self, url, headers={}): # Could be like 'attachment; filename=26897200312.avi' headers["Content-Disposition"] m = re.search(r"filename\=(.*?)$", headers["Content-Disposition"]) - try: + with contextlib.suppess(AttributeError, IndexError): filename = m.group(1) - except (AttributeError, IndexError): - pass except KeyError: pass diff --git a/ditto/core/views.py b/ditto/core/views.py index 31f115e..5841cf2 100644 --- a/ditto/core/views.py +++ b/ditto/core/views.py @@ -16,16 +16,16 @@ from .paginator import DiggPaginator if ditto_apps.is_installed("flickr"): - from ..flickr.models import Photo + from ditto.flickr.models import Photo if ditto_apps.is_installed("lastfm"): - from ..lastfm.models import Scrobble + from ditto.lastfm.models import Scrobble if ditto_apps.is_installed("pinboard"): - from ..pinboard.models import Bookmark + from ditto.pinboard.models import Bookmark if ditto_apps.is_installed("twitter"): - from ..twitter.models import Tweet + from ditto.twitter.models import Tweet class PaginatedListView(ListView): @@ -72,21 +72,18 @@ def paginate_queryset(self, queryset, page_size): page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 try: page_number = int(page) - except ValueError: + except ValueError as err: if page == "last": page_number = paginator.num_pages else: - raise Http404( - _("Page is not 'last', nor can it be converted to an int.") - ) + msg = _("Page is not 'last', nor can it be converted to an int.") + raise Http404(msg) from err try: page = paginator.page(page_number, softlimit=True) return (paginator, page, page.object_list, page.has_other_pages()) - except InvalidPage as e: - raise Http404( - _("Invalid page (%(page_number)s): %(message)s") - % {"page_number": page_number, "message": str(e)} - ) + except InvalidPage as err: + msg = _("Invalid page ({}): {}").format(page_number, str(err)) + raise Http404(msg) from err class DittoAppsMixin: @@ -113,7 +110,6 @@ class DittoAppsMixin: apps = None def __init__(self, *args, **kwargs): - self.apps = [] enabled_apps = ditto_apps.enabled() @@ -244,10 +240,10 @@ def set_app_and_variety(self, **kwargs): app_slug, variety_slug ) elif variety_slug: - raise Http404( - "'%s' is not a valid variety slug for the '%s' app slug." - % (variety_slug, app_slug) + msg = "'{}' is not a valid variety slug for the '{}' app slug.".format( + variety_slug, app_slug ) + raise Http404(msg) elif app_slug: raise Http404("'%s' is not a valid app slug." % app_slug) @@ -272,11 +268,12 @@ def get_queryset(self): queryset = self.get_queryset_for_app_variety(self.app_name, self.variety_name) if queryset is None: - raise ImproperlyConfigured( - "%(cls)s is missing a QuerySet. " - "%(cls)s.get_queryset() can't find the correct " - "queryset for this app and variety." % {"cls": self.__class__.__name__} + class_name = self.__class__.__name__ + msg = ( + f"{class_name} is missing a QuerySet. {class_name}.get_queryset() " + "can't find the correct queryset for this app and variety." ) + raise ImproperlyConfigured(msg) return queryset @@ -422,7 +419,7 @@ def get_context_data(self, **kwargs): for app_name, variety_name in self.get_app_varieties_to_display(): qs = self.get_queryset_for_app_variety(app_name, variety_name) - if self.include_twitter_replies is False: + if self.include_twitter_replies is False: # noqa: SIM102 # Don't want to include Tweets that are replies on the home page. if app_name == "twitter" and variety_name == "tweet": qs = qs.filter(in_reply_to_screen_name__exact="") @@ -629,9 +626,11 @@ def _date_from_string( format = delim.join((year_format, month_format, day_format)) datestr = delim.join((year, month, day)) try: - return datetime.datetime.strptime(force_str(datestr), format).date() - except ValueError: - raise Http404( - _("Invalid date string '%(datestr)s' given format '%(format)s'") - % {"datestr": datestr, "format": format} + return ( + datetime.datetime.strptime(force_str(datestr), format) + .astimezone(datetime.UTC) + .date() ) + except ValueError as err: + msg = f"Invalid date string '{datestr}' given format '{format}'" + raise Http404(msg) from err diff --git a/ditto/flickr/admin.py b/ditto/flickr/admin.py index f26694b..d3d7152 100644 --- a/ditto/flickr/admin.py +++ b/ditto/flickr/admin.py @@ -3,7 +3,8 @@ from django.forms import TextInput from django.utils.safestring import mark_safe -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Photo, Photoset, User @@ -440,8 +441,7 @@ class PhotoAdmin(DittoItemModelAdmin): def show_thumb(self, instance): return mark_safe( - '' - % ( + ''.format( instance.thumbnail_url, instance.thumbnail_width, instance.thumbnail_height, @@ -452,8 +452,9 @@ def show_thumb(self, instance): def show_image(self, instance): return mark_safe( - '' - % (instance.small_url, instance.small_width, instance.small_height) + ''.format( + instance.small_url, instance.small_width, instance.small_height + ) ) show_image.short_description = "Small image" diff --git a/ditto/flickr/checks.py b/ditto/flickr/checks.py index 2582493..01647ac 100644 --- a/ditto/flickr/checks.py +++ b/ditto/flickr/checks.py @@ -17,14 +17,13 @@ def check_taggit_is_installed(app_configs=None, **kwargs): ) ) - if len(checks) == 0: - if "taggit" not in settings.INSTALLED_APPS: - checks.append( - Error( - "The django-taggit app must be in INSTALLED_APPS", - hint=("Add 'taggit' to INSTALLED_APPS in your settings file."), - id="ditto.flickr.E002", - ) + if len(checks) == 0 and "taggit" not in settings.INSTALLED_APPS: + checks.append( + Error( + "The django-taggit app must be in INSTALLED_APPS", + hint=("Add 'taggit' to INSTALLED_APPS in your settings file."), + id="ditto.flickr.E002", ) + ) return checks diff --git a/ditto/flickr/factories.py b/ditto/flickr/factories.py index 64bb8a6..a684630 100644 --- a/ditto/flickr/factories.py +++ b/ditto/flickr/factories.py @@ -3,7 +3,8 @@ import factory from taggit import models as taggit_models -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models diff --git a/ditto/flickr/fetch/fetchers.py b/ditto/flickr/fetch/fetchers.py index 0f7fe76..204ddb3 100644 --- a/ditto/flickr/fetch/fetchers.py +++ b/ditto/flickr/fetch/fetchers.py @@ -7,9 +7,10 @@ from django.core.files import File from flickrapi.exceptions import FlickrError -from ...core.utils import datetime_now -from ...core.utils.downloader import DownloadException, filedownloader -from ..models import Account, User +from ditto.core.utils import datetime_now +from ditto.core.utils.downloader import DownloadException, filedownloader +from ditto.flickr.models import Account, User + from . import FetchError from .savers import PhotoSaver, PhotosetSaver, UserSaver @@ -29,7 +30,7 @@ # PhotosetsFetcher -class Fetcher(object): +class Fetcher: """Parent class for children that will call the Flickr API to fetch data. Depending on the child classes, it would be used something like: @@ -77,7 +78,8 @@ def __init__(self, account): else: self.return_value["account"] = "Unsaved Account" else: - raise ValueError("An Account object is required") + msg = "An Account object is required" + raise ValueError(msg) if account.has_credentials(): self.account = account @@ -136,30 +138,28 @@ def _fetch_page(self, **kwargs): def _not_failed(self): """Has everything gone smoothly so far? ie, no failure registered?""" - if "success" not in self.return_value or self.return_value["success"] is True: - return True - else: - return False + return ( + "success" not in self.return_value or self.return_value["success"] is True + ) def _call_api(self, **kwargs): """ Should call self.api.a_function() and set self.results with the results. """ - raise FetchError("Subclasess of Fetcher should define their own _call_api().") + msg = "Subclasess of Fetcher should define their own _call_api()." + raise FetchError(msg) def _fetch_extra(self): """Can be defined in subclasses to fetch extra data to add to self.results before we save the data in the DB.""" - pass def _save_results(self, **kwargs): """ Should go through self.results and create/update things in the DB based on its contents. """ - raise FetchError( - "Subclasses of Fetcher should define their own _save_results()." - ) + msg = "Subclasses of Fetcher should define their own _save_results()." + raise FetchError(msg) class UserIdFetcher(Fetcher): @@ -185,8 +185,9 @@ def _call_api(self): """ try: info = self.api.test.login() - except FlickrError as e: - raise FetchError("Error when calling test.login(): %s'" % e) + except FlickrError as err: + msg = f"Error when calling test.login(): {err}'" + raise FetchError(msg) from err self.results = [{"id": info["user"]["id"]}] @@ -204,7 +205,8 @@ def fetch(self, nsid=None): nsid -- A Flickr ID for a user. """ if nsid is None: - raise FetchError("UserFetcher().fetch() requires a Flickr id (NSID)") + msg = "UserFetcher().fetch() requires a Flickr id (NSID)" + raise FetchError(msg) return super().fetch(nsid=nsid) @@ -212,9 +214,9 @@ def _call_api(self, nsid): "nsid -- A Flickr ID for a user." try: info = self.api.people.getInfo(user_id=nsid) - except FlickrError as e: + except FlickrError as err: # User has deleted their account, so create a dummy result - if e.code == 5: + if err.code == 5: self.results = [ { "raw": "{}", @@ -229,9 +231,8 @@ def _call_api(self, nsid): ] return - raise FetchError( - "Error when getting info about User with Flickr ID '%s': %s" % (nsid, e) - ) + msg = f"Error when getting info about User with Flickr ID '{nsid}': {err}" + raise FetchError(msg) from err # info has 'person' and 'stat' elements. self.results = [info["person"]] @@ -253,9 +254,8 @@ def _fetch_and_save_avatar(self, user): ["image/jpeg", "image/jpg", "image/png", "image/gif"], ) - user.avatar.save( - os.path.basename(avatar_filepath), File(open(avatar_filepath, "rb")) - ) + with open(avatar_filepath, "rb") as f: + user.avatar.save(os.path.basename(avatar_filepath), File(f)) except DownloadException: pass @@ -276,9 +276,8 @@ def _call_api(self): """ Should call self.api.a_function() and set self.results with the results. """ - raise FetchError( - "Subclasess of PhotosFetcher should define their own _call_api()." - ) + msg = "Subclasess of PhotosFetcher should define their own _call_api()." + raise FetchError(msg) def _fetch_extra(self): """Before saving we need to go through the big list of photos we've @@ -286,8 +285,7 @@ def _fetch_extra(self): """ extra_results = [] - for i, photo in enumerate(self.results): - + for _i, photo in enumerate(self.results): self._fetch_user_if_missing(photo["owner"]) extra_results.append( @@ -327,10 +325,9 @@ def _fetch_photo_info(self, photo_id): """ try: results = self.api.photos.getInfo(photo_id=photo_id) - except FlickrError as e: - raise FetchError( - "Error when fetching photo info (photo %s): %s" % (photo_id, e) - ) + except FlickrError as err: + msg = f"Error when fetching photo info (photo {photo_id}): {err}" + raise FetchError(msg) from err # Each tag on the photo is added by a specific Flickr user. # (Usually, but not always, the photo owner.) @@ -348,10 +345,9 @@ def _fetch_photo_sizes(self, photo_id): """ try: results = self.api.photos.getSizes(photo_id=photo_id) - except FlickrError as e: - raise FetchError( - "Error when fetching photo sizes (photo %s): %s" % (photo_id, e) - ) + except FlickrError as err: + msg = f"Error when fetching photo sizes (photo {photo_id}): {err}" + raise FetchError(msg) from err return results["sizes"] def _fetch_photo_exif(self, photo_id): @@ -362,10 +358,9 @@ def _fetch_photo_exif(self, photo_id): """ try: results = self.api.photos.getExif(photo_id=photo_id) - except FlickrError as e: - raise FetchError( - "Error when fetching photo EXIF data (photo %s): %s" % (photo_id, e) - ) + except FlickrError as err: + msg = f"Error when fetching photo EXIF data (photo {photo_id}): {err}" + raise FetchError(msg) from err return results["photo"] def _save_results(self): @@ -389,7 +384,9 @@ def __init__(self, account): # Maximum date of photos to return, if days or start are passed in: # By default, set it before Flickr so we get everything. - self.min_date = datetime.datetime.strptime("2000-01-01", "%Y-%m-%d") + self.min_date = datetime.datetime.strptime("2000-01-01", "%Y-%m-%d").astimezone( + datetime.UTC + ) # Maximum date of photos to return, if end is passed in: self.max_date = None @@ -403,36 +400,41 @@ def fetch(self, days=None, start=None, end=None): """ if days and (start or end): - raise ValueError("You can't use --days with --start or --end") + msg = "You can't use --days with --start or --end" + raise ValueError(msg) if days: try: self.min_date = datetime_now() - datetime.timedelta(days=days) - except TypeError: + except TypeError as err: if days != "all": - raise FetchError("days should be an integer or 'all'.") + msg = "days should be an integer or 'all'." + raise FetchError(msg) from err elif start or end: try: if start: self.min_date = datetime.datetime.strptime( f"{start} 00:00:00", "%Y-%m-%d %H:%M:%S" - ) + ).astimezone(datetime.UTC) if end: self.max_date = datetime.datetime.strptime( f"{end} 23:59:59", "%Y-%m-%d %H:%M:%S" - ) + ).astimezone(datetime.UTC) if (start and end) and (start > end): - raise ValueError("Start date must be before the end date.") + msg = "Start date must be before the end date." + raise ValueError(msg) - except TypeError: - raise FetchError( + except TypeError as err: + msg = ( "Something went wrong with start or end. Please check the date " "format. It should be YYYY-MM-DD" ) + raise FetchError(msg) from err else: - raise FetchError("Either set days or start and/or end.") + msg = "Either set days or start and/or end." + raise FetchError(msg) return super().fetch() @@ -459,11 +461,9 @@ def _call_api(self): try: results = self.api.people.getPhotos(**api_args) - except FlickrError as e: - raise FetchError( - "Error when fetching recent photos (page %s): %s" - % (self.page_number, e) - ) + except FlickrError as err: + msg = f"Error when fetching recent photos (page {self.page_number}: {err}" + raise FetchError(msg) from err if ( self.page_number == 1 @@ -490,10 +490,9 @@ def _call_api(self): per_page=self.items_per_page, page=self.page_number, ) - except FlickrError as e: - raise FetchError( - "Error when fetching photosets (page %s): %s" % (self.page_number, e) - ) + except FlickrError as err: + msg = f"Error when fetching photosets (page {self.page_number}): {err}" + raise FetchError(msg) from err if ( self.page_number == 1 @@ -511,8 +510,7 @@ def _fetch_extra(self): extra_results = [] - for i, photoset in enumerate(self.results): - + for _i, photoset in enumerate(self.results): photos = self._fetch_photos_in_photoset(photoset["id"]) extra_results.append( @@ -552,11 +550,11 @@ def _fetch_photos_in_photoset(self, photoset_id): per_page=self.items_per_page, page=page_number, ) - except FlickrError as e: - raise FetchError( - "Error when fetching photos in photoset %s (page %s): %s" - % (photoset_id, page_number, e) + except FlickrError as err: + msg = "Error when fetching photos in photoset {} (page {}): {}".format( + photoset_id, page_number, err ) + raise FetchError(msg) from err if "photoset" in results and "photo" in results["photoset"]: total_pages = results["photoset"]["pages"] diff --git a/ditto/flickr/fetch/filesfetchers.py b/ditto/flickr/fetch/filesfetchers.py index d780ad4..ba800de 100644 --- a/ditto/flickr/fetch/filesfetchers.py +++ b/ditto/flickr/fetch/filesfetchers.py @@ -2,15 +2,16 @@ from django.core.files import File -from ...core.utils.downloader import DownloadException, filedownloader -from ..models import Photo +from ditto.core.utils.downloader import DownloadException, filedownloader +from ditto.flickr.models import Photo + from . import FetchError # A single class that fetches original photo/video files for existing # Photo objects. Doesn't use the API. -class OriginalFilesFetcher(object): +class OriginalFilesFetcher: """ Fetch the original photo files for a single Account. @@ -45,7 +46,7 @@ def __init__(self, account): self.account = account - def fetch(self, fetch_all=False): + def fetch(self, *, fetch_all=False): """ Download and save original photos and videos for all Photo objects (or just those that don't already have them). @@ -132,13 +133,13 @@ def _fetch_and_save_file(self, photo, media_type): try: # Saves the file to /tmp/: filepath = filedownloader.download(url, acceptable_content_types) - except DownloadException as e: - raise FetchError(e) + except DownloadException as err: + raise FetchError(err) from err if filepath: # Reopen file and save to the Photo: - reopened_file = open(filepath, "rb") - django_file = File(reopened_file) + with open(filepath, "rb") as reopened_file: + django_file = File(reopened_file) if media_type == "video": photo.video_original_file.save(os.path.basename(filepath), django_file) diff --git a/ditto/flickr/fetch/multifetchers.py b/ditto/flickr/fetch/multifetchers.py index a948034..de50429 100644 --- a/ditto/flickr/fetch/multifetchers.py +++ b/ditto/flickr/fetch/multifetchers.py @@ -1,4 +1,5 @@ -from ..models import Account, User +from ditto.flickr.models import Account, User + from . import FetchError from .fetchers import PhotosetsFetcher, RecentPhotosFetcher from .filesfetchers import OriginalFilesFetcher @@ -16,7 +17,7 @@ # OriginalFilesMultiAccountFetcher -class MultiAccountFetcher(object): +class MultiAccountFetcher: """Parent class for fetching things from Flickr for ALL or ONE Account(s). Its child classes are useful for: @@ -51,32 +52,33 @@ def __init__(self, nsid=None): # Get all active Accounts. self.accounts = list(Account.objects.filter(is_active=True)) if len(self.accounts) == 0: - raise FetchError("No active Accounts were found to fetch.") + msg = "No active Accounts were found to fetch." + raise FetchError(msg) else: # Find the Account associated with nsid. try: user = User.objects.get(nsid=nsid) - except User.DoesNotExist: - raise FetchError("There is no User with the NSID '%s'" % nsid) + except User.DoesNotExist as err: + msg = f"There is no User with the NSID '{nsid}'" + raise FetchError(msg) from err try: account = Account.objects.get(user=user) - except Account.DoesNotExist: - raise FetchError( - "There is no Account associated with the User with NSID '%s'" % nsid - ) + except Account.DoesNotExist as err: + msg = "There is no Account associated with the User with NSID '{nsid}'" + raise FetchError(msg) from err if account.is_active is False: - raise FetchError( + msg = ( "The Account associated with the User with NSID " - "'%s' is marked as inactive." % nsid + f"'{nsid}' is marked as inactive." ) + raise FetchError(msg) self.accounts = [account] return super().__init__() def fetch(self, **kwargs): - raise FetchError( - "Subclasess of MultiAccountFetcher should define their own fetch()." - ) + msg = "Subclasess of MultiAccountFetcher should define their own fetch()." + raise FetchError(msg) class RecentPhotosMultiAccountFetcher(MultiAccountFetcher): @@ -130,7 +132,7 @@ class OriginalFilesMultiAccountFetcher(MultiAccountFetcher): went wrong) for each account. """ - def fetch(self, fetch_all=False): + def fetch(self, *, fetch_all=False): for account in self.accounts: self.return_value.append( OriginalFilesFetcher(account).fetch(fetch_all=fetch_all) diff --git a/ditto/flickr/fetch/savers.py b/ditto/flickr/fetch/savers.py index a333a83..c5a8f80 100644 --- a/ditto/flickr/fetch/savers.py +++ b/ditto/flickr/fetch/savers.py @@ -1,11 +1,13 @@ +import contextlib import json -from datetime import datetime, timezone +from datetime import UTC, datetime from zoneinfo import ZoneInfo from django.db.utils import IntegrityError from taggit.models import Tag -from ..models import Photo, Photoset, User +from ditto.flickr.models import Photo, Photoset, User + from . import FetchError # These classes are passed JSON data from the Flickr API and create/update @@ -22,7 +24,7 @@ # PhotosetSave -class SaveUtilsMixin(object): +class SaveUtilsMixin: """Handy utility methods used by the *Saver classes.""" def __init__(self, *args, **kwargs): @@ -42,10 +44,10 @@ def _unixtime_to_datetime(self, api_time): """Change a text unixtime from the API to a datetime with timezone. api_time is a string or int like "1093459273". """ - return datetime.utcfromtimestamp(int(api_time)).replace(tzinfo=timezone.utc) + return datetime.fromtimestamp(int(api_time), tz=UTC) -class UserSaver(SaveUtilsMixin, object): +class UserSaver(SaveUtilsMixin): """For creating/updating an individual User based on data from the API. Use like: @@ -115,7 +117,7 @@ def save_user(self, user, fetch_time): return user_obj -class PhotoSaver(SaveUtilsMixin, object): +class PhotoSaver(SaveUtilsMixin): """For creating/updating an individual Photo based on data from the API. Use like: @@ -291,8 +293,8 @@ def _save_tags(self, photo_obj, tags_data): # The existing tag-photo relationships. tagged_photos = Photo.tags.through.objects.filter(content_object=photo_obj) - local_flickr_ids = set([]) - remote_flickr_ids = set([]) + local_flickr_ids = set() + remote_flickr_ids = set() # Get the Flickr IDs of all the current tag-photo relationships. for tagged_photo in tagged_photos: @@ -302,7 +304,6 @@ def _save_tags(self, photo_obj, tags_data): remote_flickr_ids.add(tag["id"]) if tag["id"] not in local_flickr_ids: - # This tag isn't currently on the photo, so add it. try: tag_obj, tag_created = Tag.objects.get_or_create( @@ -325,11 +326,12 @@ def _save_tags(self, photo_obj, tags_data): # data. try: user = User.objects.get(nsid=tag["author"]) - except User.DoesNotExist: - raise FetchError( + except User.DoesNotExist as err: + msg = ( "Tried to add a Tag authored by a Flickr user " - "with NSID %s who doesn't exist in the DB." % tag["author"] + f"with NSID {tag['author']} who doesn't exist in the DB." ) + raise FetchError(msg) from err pt_obj = Photo.tags.through( flickr_id=tag["id"], @@ -349,7 +351,7 @@ def _save_tags(self, photo_obj, tags_data): tagged_photo.delete() -class PhotosetSaver(SaveUtilsMixin, object): +class PhotosetSaver(SaveUtilsMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -389,10 +391,8 @@ def save_photoset(self, photoset): "photos_raw": json.dumps(photoset["photos"]), } - try: + with contextlib.suppress(Photo.DoesNotExist): defaults["primary_photo"] = Photo.objects.get(flickr_id=ps["primary"]) - except Photo.DoesNotExist: - pass photoset_obj, created = Photoset.objects.update_or_create( flickr_id=ps["id"], defaults=defaults @@ -403,10 +403,8 @@ def save_photoset(self, photoset): # photoset object. photos = [] for photo in photoset["photos"]: - try: + with contextlib.suppress(Photo.DoesNotExist): photos.append(Photo.objects.get(flickr_id=photo["id"])) - except Photo.DoesNotExist: - pass # Sets/updates the SortedManyToMany field of the photoset's photos: photoset_obj.photos.set(photos) diff --git a/ditto/flickr/management/commands/__init__.py b/ditto/flickr/management/commands/__init__.py index e80c7e1..a04e346 100644 --- a/ditto/flickr/management/commands/__init__.py +++ b/ditto/flickr/management/commands/__init__.py @@ -1,6 +1,6 @@ from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand +from ditto.core.management.commands import DittoBaseCommand class FetchCommand(DittoBaseCommand): @@ -25,7 +25,6 @@ def add_arguments(self, parser): class FetchPhotosCommand(FetchCommand): - # What we're fetching: singular_noun = "Photo" plural_noun = "Photos" @@ -47,20 +46,20 @@ def add_arguments(self, parser): parser.add_argument("--end", action="store", default=None, help=self.end_help) def handle(self, *args, **options): - # We might be fetching for a specific account or all (None). nsid = options["account"] if options["account"] else None if options["days"] and (options["start"] or options["end"]): - raise CommandError("You can't use --days with --start or --end") + msg = "You can't use --days with --start or --end" + raise CommandError(msg) if options["days"]: - # Will be either 'all' or a number; make the number an int. if options["days"].isdigit(): options["days"] = int(options["days"]) elif options["days"] != "all": - raise CommandError("--days should be an integer or 'all'.") + msg = "--days should be an integer or 'all'." + raise CommandError(msg) results = self.fetch_photos(nsid=nsid, days=options["days"]) self.output_results(results, options.get("verbosity", 1)) @@ -72,12 +71,12 @@ def handle(self, *args, **options): self.output_results(results, options.get("verbosity", 1)) elif options["account"]: - raise CommandError( - "Specify --days, or --start and/or --end as well as --account." - ) + msg = "Specify --days, or --start and/or --end as well as --account." + raise CommandError(msg) else: - raise CommandError("Specify --days, or --start and/or --end.") + msg = "Specify --days, or --start and/or --end." + raise CommandError(msg) def fetch_photos(self, nsid, days=None, start=None, end=None): """Child classes should override this method to call a method that diff --git a/ditto/flickr/management/commands/fetch_flickr_account_user.py b/ditto/flickr/management/commands/fetch_flickr_account_user.py index eb377e6..b0b9572 100644 --- a/ditto/flickr/management/commands/fetch_flickr_account_user.py +++ b/ditto/flickr/management/commands/fetch_flickr_account_user.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand, CommandError -from ...fetch.fetchers import UserFetcher, UserIdFetcher -from ...models import Account, User +from ditto.flickr.fetch.fetchers import UserFetcher, UserIdFetcher +from ditto.flickr.models import Account, User class Command(BaseCommand): @@ -25,7 +25,8 @@ def add_arguments(self, parser): def handle(self, *args, **options): if options["id"] is False: - raise CommandError("Specify an Account ID like --id=1") + msg = "Specify an Account ID like --id=1" + raise CommandError(msg) # First we need the Account object we're fetching for. account = False @@ -57,8 +58,9 @@ def handle(self, *args, **options): else: if options.get("verbosity", 1) > 0: self.stderr.write( - "Failed to fetch a user using Flickr ID '%s': %s" - % (id_result["id"], result["messages"][0]) + "Failed to fetch a user using Flickr ID '{}': {}".format( + id_result["id"], result["messages"][0] + ) ) else: if options.get("verbosity", 1) > 0: diff --git a/ditto/flickr/management/commands/fetch_flickr_originals.py b/ditto/flickr/management/commands/fetch_flickr_originals.py index 875398f..f628abd 100644 --- a/ditto/flickr/management/commands/fetch_flickr_originals.py +++ b/ditto/flickr/management/commands/fetch_flickr_originals.py @@ -1,4 +1,5 @@ -from ...fetch.multifetchers import OriginalFilesMultiAccountFetcher +from ditto.flickr.fetch.multifetchers import OriginalFilesMultiAccountFetcher + from . import FetchCommand @@ -39,5 +40,5 @@ def handle(self, *args, **options): results = self.fetch_files(nsid, options["all"]) self.output_results(results, options.get("verbosity", 1)) - def fetch_files(self, nsid, fetch_all=False): + def fetch_files(self, nsid, *, fetch_all=False): return OriginalFilesMultiAccountFetcher(nsid=nsid).fetch(fetch_all=fetch_all) diff --git a/ditto/flickr/management/commands/fetch_flickr_photos.py b/ditto/flickr/management/commands/fetch_flickr_photos.py index cffb3a4..8b41ea1 100644 --- a/ditto/flickr/management/commands/fetch_flickr_photos.py +++ b/ditto/flickr/management/commands/fetch_flickr_photos.py @@ -1,4 +1,5 @@ -from ...fetch.multifetchers import RecentPhotosMultiAccountFetcher +from ditto.flickr.fetch.multifetchers import RecentPhotosMultiAccountFetcher + from . import FetchPhotosCommand diff --git a/ditto/flickr/management/commands/fetch_flickr_photosets.py b/ditto/flickr/management/commands/fetch_flickr_photosets.py index 417ba5a..2f59a76 100644 --- a/ditto/flickr/management/commands/fetch_flickr_photosets.py +++ b/ditto/flickr/management/commands/fetch_flickr_photosets.py @@ -1,4 +1,5 @@ -from ...fetch.multifetchers import PhotosetsMultiAccountFetcher +from ditto.flickr.fetch.multifetchers import PhotosetsMultiAccountFetcher + from . import FetchCommand diff --git a/ditto/flickr/managers.py b/ditto/flickr/managers.py index 4537565..e2db63b 100644 --- a/ditto/flickr/managers.py +++ b/ditto/flickr/managers.py @@ -1,7 +1,7 @@ from django.db import models from taggit.managers import _TaggableManager -from ..core.managers import PublicItemManager +from ditto.core.managers import PublicItemManager class PhotosManager(models.Manager): diff --git a/ditto/flickr/models.py b/ditto/flickr/models.py index ad06ac9..a0a51ed 100644 --- a/ditto/flickr/models.py +++ b/ditto/flickr/models.py @@ -1,19 +1,13 @@ -# coding: utf-8 from django.db import models - -try: - from django.urls import reverse -except ImportError: - # For Django 1.8 - from django.urls import reverse - from django.templatetags.static import static +from django.urls import reverse from imagekit.cachefiles import ImageCacheFile from sortedm2m.fields import SortedManyToManyField from taggit.managers import TaggableManager from taggit.models import TaggedItemBase -from ..core.models import DiffModelMixin, DittoItemModel, TimeStampedModelMixin +from ditto.core.models import DiffModelMixin, DittoItemModel, TimeStampedModelMixin + from . import app_settings, imagegenerators, managers @@ -27,15 +21,15 @@ class Account(TimeStampedModelMixin, models.Model): default=True, help_text="If false, new Photos won't be fetched." ) + class Meta: + ordering = ["user__realname"] + def __str__(self): if self.user: return str(self.user) else: return "%d" % self.pk - class Meta: - ordering = ["user__realname"] - def get_absolute_url(self): if self.user: return reverse("flickr:user_detail", kwargs={"nsid": self.user.nsid}) @@ -44,10 +38,7 @@ def get_absolute_url(self): def has_credentials(self): "Does this at least have something in its API fields? True or False" - if self.api_key and self.api_secret: - return True - else: - return False + return self.api_key and self.api_secret class TaggedPhoto(TaggedItemBase): @@ -94,7 +85,6 @@ class Meta: class Photo(DittoItemModel, ExtraPhotoManagers): - ditto_item_name = "flickr_photo" # The keys in this dict are what we use internally, for method names and @@ -527,7 +517,7 @@ def get_next_public_by_post_time(self): .order_by("post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass def get_previous_public_by_post_time(self): @@ -541,7 +531,7 @@ def get_previous_public_by_post_time(self): .order_by("-post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass # Shortcuts: @@ -562,7 +552,7 @@ def account(self): @property def safety_level_str(self): "Returns the text version of the safety_level, eg 'Restricted'." - levels = dict((x, y) for x, y in self.SAFETY_LEVELS) + levels = {x: y for x, y in self.SAFETY_LEVELS} try: return levels[self.safety_level] @@ -662,7 +652,7 @@ def _local_image_url(self, size): image_generator = generator(source=self.original_file) result = ImageCacheFile(image_generator) return result.url - except Exception: + except Exception: # noqa: BLE001 # We have an original file but something's wrong with it. # Might be 0 bytes or something. return static("ditto-core/img/original_error.jpg") @@ -676,7 +666,7 @@ def _remote_image_url(self, size): size -- One of the keys from self.PHOTO_SIZES. """ if size == "original": - return "https://farm%s.static.flickr.com/%s/%s_%s_%s.%s" % ( + return "https://farm{}.static.flickr.com/{}/{}_{}_{}.{}".format( self.farm, self.server, self.flickr_id, @@ -688,8 +678,8 @@ def _remote_image_url(self, size): size_ext = "" # Medium size doesn't have a letter suffix. if self.PHOTO_SIZES[size]["suffix"]: - size_ext = "_%s" % self.PHOTO_SIZES[size]["suffix"] - return "https://farm%s.static.flickr.com/%s/%s_%s%s.jpg" % ( + size_ext = "_{}".format(self.PHOTO_SIZES[size]["suffix"]) + return "https://farm{}.static.flickr.com/{}/{}_{}{}.jpg".format( self.farm, self.server, self.flickr_id, @@ -718,12 +708,9 @@ def _remote_video_url(self, size): if self.media == "photo": return None else: - if size == "video_original": - secret = self.original_secret - else: - secret = self.secret + secret = self.original_secret if size == "video_original" else self.secret url_size = self.VIDEO_SIZES[size]["url_size"] - return "%splay/%s/%s/" % (self.permalink, url_size, secret) + return f"{self.permalink}play/{url_size}/{secret}/" def __getattr__(self, name): """ @@ -805,16 +792,12 @@ class Photoset(TimeStampedModelMixin, DiffModelMixin, models.Model): # Returns ALL photos, public AND private. photos = SortedManyToManyField("Photo", related_name="photosets") - def public_photos(self): - "Returns only public photos." - return self.photos.filter(is_private=False) + class Meta: + ordering = ["-flickr_created_time"] def __str__(self): return self.title - class Meta: - ordering = ["-flickr_created_time"] - def get_absolute_url(self): return reverse( "flickr:photoset_detail", @@ -823,10 +806,30 @@ def get_absolute_url(self): @property def permalink(self): - return "https://www.flickr.com/photos/%s/albums/%s" % ( - self.user.nsid, - self.flickr_id, - ) + return f"https://www.flickr.com/photos/{self.user.nsid}/albums/{self.flickr_id}" + + def public_photos(self): + "Returns only public photos." + return self.photos.filter(is_private=False) + + +def avatar_upload_path(obj, filename): + "Make path under MEDIA_ROOT where avatar file will be saved." + # If NSID is '35034346050@N01', get '35034346050' + nsid = obj.nsid[: obj.nsid.index("@")] + + # If NSID is '35034346050@N01' + # then, 'flickr/60/50/35034346050/avatars/avatar_name.jpg' + return "/".join( + [ + app_settings.FLICKR_DIR_BASE, + nsid[-4:-2], + nsid[-2:], + obj.nsid.replace("@", ""), + "avatars", + filename, + ] + ) class User(TimeStampedModelMixin, DiffModelMixin, models.Model): @@ -872,24 +875,6 @@ class User(TimeStampedModelMixin, DiffModelMixin, models.Model): null=False, blank=False, max_length=50, help_text="eg, 'Europe/London'." ) - def avatar_upload_path(self, filename): - "Make path under MEDIA_ROOT where avatar file will be saved." - # If NSID is '35034346050@N01', get '35034346050' - nsid = self.nsid[: self.nsid.index("@")] - - # If NSID is '35034346050@N01' - # then, 'flickr/60/50/35034346050/avatars/avatar_name.jpg' - return "/".join( - [ - app_settings.FLICKR_DIR_BASE, - nsid[-4:-2], - nsid[-2:], - self.nsid.replace("@", ""), - "avatars", - filename, - ] - ) - avatar = models.ImageField( upload_to=avatar_upload_path, null=False, blank=True, default="" ) @@ -898,12 +883,12 @@ def avatar_upload_path(self, filename): # All Users that have Accounts: objects_with_accounts = managers.WithAccountsManager() - def __str__(self): - return self.realname if self.realname else self.username - class Meta: ordering = ["realname", "username"] + def __str__(self): + return self.realname if self.realname else self.username + def get_absolute_url(self): return reverse("flickr:user_detail", kwargs={"nsid": self.nsid}) @@ -926,7 +911,7 @@ def avatar_url(self): def original_icon_url(self): """URL of the avatar/profile pic at Flickr.""" if self.iconserver: - return "https://farm%s.staticflickr.com/%s/buddyicons/%s.jpg" % ( + return "https://farm{}.staticflickr.com/{}/buddyicons/{}.jpg".format( self.iconfarm, self.iconserver, self.nsid, diff --git a/ditto/flickr/templatetags/ditto_flickr.py b/ditto/flickr/templatetags/ditto_flickr.py index ce52ee4..57e22ab 100644 --- a/ditto/flickr/templatetags/ditto_flickr.py +++ b/ditto/flickr/templatetags/ditto_flickr.py @@ -1,12 +1,11 @@ -from datetime import datetime +from datetime import UTC, datetime from datetime import time as datetime_time -from datetime import timezone from django import template from django.utils.html import format_html -from ...core.utils import get_annual_item_counts -from ..models import Photo, Photoset +from ditto.core.utils import get_annual_item_counts +from ditto.flickr.models import Photo, Photoset register = template.Library() @@ -45,8 +44,8 @@ def day_photos(date, nsid=None, time="post_time"): "`time` must be either 'post_time' or " "'taken_time', not '%s'." % time ) - start = datetime.combine(date, datetime_time.min).replace(tzinfo=timezone.utc) - end = datetime.combine(date, datetime_time.max).replace(tzinfo=timezone.utc) + start = datetime.combine(date, datetime_time.min).replace(tzinfo=UTC) + end = datetime.combine(date, datetime_time.max).replace(tzinfo=UTC) photos = Photo.public_photo_objects if time == "taken_time": @@ -81,13 +80,14 @@ def photo_license(n): """Returns the text value of the Photo's license, indicated by the number n. Will probably be an HTML link to more info. """ - licenses = dict((x, y) for x, y in Photo.LICENSES) + licenses = {x: y for x, y in Photo.LICENSES} if n in licenses: if n in Photo.LICENSE_URLS and Photo.LICENSE_URLS[n] != "": return format_html( - '%(name)s' - % {"url": Photo.LICENSE_URLS[n], "name": licenses[n]} + '{}'.format( + Photo.LICENSE_URLS[n], licenses[n] + ) ) else: return licenses[n] @@ -118,9 +118,6 @@ def annual_photo_counts(nsid=None, count_by="post_time"): if nsid is not None: qs = qs.filter(user__nsid=nsid) - if count_by == "taken_time": - field_name = "taken_year" - else: - field_name = "post_year" + field_name = "taken_year" if count_by == "taken_time" else "post_year" return get_annual_item_counts(qs, field_name) diff --git a/ditto/flickr/views.py b/ditto/flickr/views.py index 164341e..778c54d 100644 --- a/ditto/flickr/views.py +++ b/ditto/flickr/views.py @@ -4,11 +4,12 @@ from django.views.generic.detail import SingleObjectMixin from taggit.models import Tag -from ..core.views import PaginatedListView +from ditto.core.views import PaginatedListView + from .models import Account, Photo, Photoset, User -class PhotosOrderMixin(object): +class PhotosOrderMixin: """ For pages which list Photos and can change the order they're viewed in. Can have 'order' in the GET string, with values of 'uploaded' or 'taken'. @@ -41,14 +42,11 @@ def get_queryset(self): # wasn't ordering by taken_time without this. ordering = self.get_ordering() if ordering: - if ordering == "-taken_time": # Exclude where we don't know the taken time. queryset = queryset.filter(taken_unknown=False) - import six - - if isinstance(ordering, six.string_types): + if isinstance(ordering, str): ordering = (ordering,) queryset = queryset.order_by(*ordering) @@ -173,6 +171,7 @@ def get_context_data(self, **kwargs): class TagDetailView(PhotosOrderMixin, SingleObjectMixin, PaginatedListView): "All Photos with a certain tag from all Accounts" + template_name = "flickr/tag_detail.html" allow_empty = False queryset = Photo.public_objects.prefetch_related("user") @@ -196,6 +195,7 @@ def get_queryset(self): class UserTagDetailView(PhotosOrderMixin, SingleUserMixin, PaginatedListView): "All Photos with a certain Tag from one User" + template_name = "flickr/user_tag_detail.html" allow_empty = False queryset = Photo.public_objects.prefetch_related("user") @@ -208,8 +208,8 @@ def get_tag_object(self): """Custom method for fetching the Tag.""" try: obj = Tag.objects.get(slug=self.kwargs["tag_slug"]) - except Tag.DoesNotExist: - raise Http404(_("No Tags found matching the query")) + except Tag.DoesNotExist as err: + raise Http404(_("No Tags found matching the query")) from err return obj def get_context_data(self, **kwargs): @@ -263,6 +263,6 @@ def get_photoset_object(self): obj = Photoset.objects.get( user=self.object, flickr_id=self.kwargs["flickr_id"] ) - except Photoset.DoesNotExist: - raise Http404(_("No Photosets found matching the query")) + except Photoset.DoesNotExist as err: + raise Http404(_("No Photosets found matching the query")) from err return obj diff --git a/ditto/lastfm/admin.py b/ditto/lastfm/admin.py index cf79558..8933780 100644 --- a/ditto/lastfm/admin.py +++ b/ditto/lastfm/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Album, Artist, Scrobble, Track diff --git a/ditto/lastfm/factories.py b/ditto/lastfm/factories.py index 813e62e..ac78914 100644 --- a/ditto/lastfm/factories.py +++ b/ditto/lastfm/factories.py @@ -1,6 +1,7 @@ import factory -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models diff --git a/ditto/lastfm/fetch.py b/ditto/lastfm/fetch.py index 262efce..1bf03f7 100644 --- a/ditto/lastfm/fetch.py +++ b/ditto/lastfm/fetch.py @@ -2,13 +2,13 @@ import json import time import urllib -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import requests from ditto import TITLE, VERSION +from ditto.core.utils import datetime_now -from ..core.utils import datetime_now from .models import Account, Album, Artist, Scrobble, Track from .utils import slugify_name @@ -19,7 +19,7 @@ class FetchError(Exception): pass -class ScrobblesFetcher(object): +class ScrobblesFetcher: """ Fetches scrobbles from the API for one Account. @@ -36,7 +36,6 @@ class ScrobblesFetcher(object): items_per_page = 200 def __init__(self, account): - # Will be an Account object, passed into init() self.account = None @@ -55,7 +54,8 @@ def __init__(self, account): if isinstance(account, Account): self.return_value["account"] = str(account) else: - raise ValueError("An Account object is required") + msg = "An Account object is required" + raise ValueError(msg) if account.has_credentials(): self.account = account @@ -95,8 +95,9 @@ def fetch(self, fetch_type="recent", days=None): if fetch_type == "days": try: test = days + 1 # noqa: F841 - except TypeError: - raise ValueError("days argument should be an integer") + except TypeError as err: + msg = "days argument should be an integer" + raise ValueError(msg) from err self.min_datetime = datetime_now() - timedelta(days=days) @@ -145,10 +146,9 @@ def _fetch_page(self): def _not_failed(self): """Has everything gone smoothly so far? ie, no failure registered?""" - if "success" not in self.return_value or self.return_value["success"] is True: - return True - else: - return False + return ( + "success" not in self.return_value or self.return_value["success"] is True + ) def _api_method(self): "The name of the API method." @@ -180,28 +180,27 @@ def _send_request(self): """ query_string = urllib.parse.urlencode(self._api_args()) - url = "{}?{}".format(LASTFM_API_ENDPOINT, query_string) + url = f"{LASTFM_API_ENDPOINT}?{query_string}" try: response = requests.get( - url, headers={"User-Agent": "Mozilla/5.0 (%s v%s)" % (TITLE, VERSION)} + url, + headers={"User-Agent": f"Mozilla/5.0 ({TITLE} v{VERSION})"}, ) response.raise_for_status() # Raises an exception on HTTP error. - except requests.exceptions.RequestException as e: - raise FetchError( - "Error when fetching Scrobbles (page %s): %s" - % (self.page_number, str(e)) - ) + except requests.exceptions.RequestException as err: + msg = "Error when fetching Scrobbles (page {self.page_number}): {err}" + raise FetchError(msg) from err response.encoding = "utf-8" results = json.loads(response.text) if "error" in results: - raise FetchError( - "Error %s when fetching Scrobbles (page %s): %s" - % (results["error"], self.page_number, results["message"]) + msg = "Error {} when fetching Scrobbles (page {}): {}".format( + results["error"], self.page_number, results["message"] ) + raise FetchError(msg) # Set total number of pages first time round: attr = results["recenttracks"]["@attr"] @@ -257,9 +256,7 @@ def _save_scrobble(self, scrobble, fetch_time): ) # Unixtime to datetime object: - scrobble_time = datetime.utcfromtimestamp(int(scrobble["date"]["uts"])).replace( - tzinfo=timezone.utc - ) + scrobble_time = datetime.fromtimestamp(int(scrobble["date"]["uts"]), tz=UTC) scrobble_obj, created = Scrobble.objects.update_or_create( account=self.account, @@ -304,7 +301,7 @@ def _get_slugs(self, scrobble_url): return artist_slug, track_slug -class ScrobblesMultiAccountFetcher(object): +class ScrobblesMultiAccountFetcher: """ For fetching Scrobbles for ALL or ONE account(s). @@ -334,20 +331,20 @@ def __init__(self, username=None): # Get all active Accounts. self.accounts = list(Account.objects.filter(is_active=True)) if len(self.accounts) == 0: - raise FetchError("No active Accounts were found to fetch.") + msg = "No active Accounts were found to fetch." + raise FetchError(msg) else: # Find the Account associated with username. try: account = Account.objects.get(username=username) - except Account.DoesNotExist: - raise FetchError( - "There is no Account with the username '%s'" % username - ) + except Account.DoesNotExist as err: + msg = f"There is no Account with the username '{username}'" + raise FetchError(msg) from err if account.is_active is False: - raise FetchError( - "The Account with the username '%s' is marked as inactive." - % username + msg = ( + "The Account with the username '{username}' is marked as inactive." ) + raise FetchError(msg) self.accounts = [account] def fetch(self, **kwargs): diff --git a/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py b/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py index f2fe7b9..beca421 100644 --- a/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py +++ b/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py @@ -1,11 +1,10 @@ from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand -from ...fetch import ScrobblesMultiAccountFetcher +from ditto.core.management.commands import DittoBaseCommand +from ditto.lastfm.fetch import ScrobblesMultiAccountFetcher class Command(DittoBaseCommand): - # What we're fetching: singular_noun = "Scrobble" plural_noun = "Scrobbles" @@ -47,7 +46,8 @@ def handle(self, *args, **options): fetch_type = "all" else: - raise CommandError("--days should be an integer or 'all'.") + msg = "--days should be an integer or 'all'." + raise CommandError(msg) fetcher = ScrobblesMultiAccountFetcher(username=username) diff --git a/ditto/lastfm/managers.py b/ditto/lastfm/managers.py index 587bb9f..70a80e9 100644 --- a/ditto/lastfm/managers.py +++ b/ditto/lastfm/managers.py @@ -67,13 +67,16 @@ def with_scrobble_counts(self, **kwargs): track = kwargs.get("track", None) if album and not self.is_filterable_by_album: - raise ValueError("This is not filterable by album") + msg = "This is not filterable by album" + raise ValueError(msg) if artist and not self.is_filterable_by_artist: - raise ValueError("This is not filterable by artist") + msg = "This is not filterable by artist" + raise ValueError(msg) if track and not self.is_filterable_by_track: - raise ValueError("This is not filterable by track") + msg = "This is not filterable by track" + raise ValueError(msg) if account is not None and account.__class__.__name__ != "Account": raise TypeError( @@ -144,11 +147,7 @@ class TracksManager(WithScrobbleCountsManager): def with_scrobble_counts(self, **kwargs): "Pre-fetch all the Tracks' Artists." - qs = ( - super(TracksManager, self) - .with_scrobble_counts(**kwargs) - .prefetch_related("artist") - ) + qs = super().with_scrobble_counts(**kwargs).prefetch_related("artist") return qs @@ -163,11 +162,7 @@ class AlbumsManager(WithScrobbleCountsManager): def with_scrobble_counts(self, **kwargs): "Pre-fetch all the Albums' Artists." - qs = ( - super(AlbumsManager, self) - .with_scrobble_counts(**kwargs) - .prefetch_related("artist") - ) + qs = super().with_scrobble_counts(**kwargs).prefetch_related("artist") return qs diff --git a/ditto/lastfm/models.py b/ditto/lastfm/models.py index 2662c59..cb8144e 100644 --- a/ditto/lastfm/models.py +++ b/ditto/lastfm/models.py @@ -1,14 +1,9 @@ -# coding: utf-8 from django.db import models +from django.urls import reverse -try: - from django.urls import reverse -except ImportError: - # For Django 1.8 - from django.urls import reverse +from ditto.core.models import DittoItemModel, TimeStampedModelMixin +from ditto.core.utils import truncate_string -from ..core.models import DittoItemModel, TimeStampedModelMixin -from ..core.utils import truncate_string from . import managers # For generating permalinks. @@ -38,25 +33,22 @@ class Account(TimeStampedModelMixin, models.Model): default=True, help_text="If false, new scrobbles won't be fetched." ) - def __str__(self): - return self.realname - class Meta: ordering = ["username"] + def __str__(self): + return self.realname + def get_absolute_url(self): return reverse("lastfm:user_detail", kwargs={"username": self.username}) @property def permalink(self): - return "%s/user/%s" % (LASTFM_URL_ROOT, self.username) + return f"{LASTFM_URL_ROOT}/user/{self.username}" def has_credentials(self): "Does this at least have something in its API field? True or False" - if self.api_key: - return True - else: - return False + return bool(self.api_key) def get_recent_scrobbles(self, limit=10): return self.scrobbles.prefetch_related("artist", "track").order_by( @@ -103,12 +95,12 @@ class Album(TimeStampedModelMixin, models.Model): objects = managers.AlbumsManager() - def __str__(self): - return self.name - class Meta: ordering = ["name"] + def __str__(self): + return self.name + def get_absolute_url(self): "The Album's URL locally." return reverse( @@ -119,7 +111,7 @@ def get_absolute_url(self): @property def permalink(self): "The Album's URL at Last.fm." - return "%s/music/%s/%s" % ( + return "{}/music/{}/{}".format( LASTFM_URL_ROOT, self.artist.original_slug, self.original_slug, @@ -128,7 +120,7 @@ def permalink(self): @property def musicbrainz_url(self): if self.mbid: - return "%s/release/%s" % (MUSICBRAINZ_URL_ROOT, self.mbid) + return f"{MUSICBRAINZ_URL_ROOT}/release/{self.mbid}" else: return None @@ -178,12 +170,12 @@ class Artist(TimeStampedModelMixin, models.Model): objects = managers.ArtistsManager() - def __str__(self): - return self.name - class Meta: ordering = ["name"] + def __str__(self): + return self.name + def get_absolute_url(self): "The Artist's URL locally." return reverse("lastfm:artist_detail", kwargs={"artist_slug": self.slug}) @@ -191,12 +183,12 @@ def get_absolute_url(self): @property def permalink(self): "The Artist's URL at Last.fm." - return "%s/music/%s" % (LASTFM_URL_ROOT, self.original_slug) + return f"{LASTFM_URL_ROOT}/music/{self.original_slug}" @property def musicbrainz_url(self): if self.mbid: - return "%s/artist/%s" % (MUSICBRAINZ_URL_ROOT, self.mbid) + return f"{MUSICBRAINZ_URL_ROOT}/artist/{self.mbid}" else: return None @@ -281,15 +273,15 @@ class Scrobble(DittoItemModel, models.Model): null=True, ) - def __str__(self): - return "%s (%s)" % (self.title, self.post_time) - class Meta: ordering = ["-post_time"] + def __str__(self): + return f"{self.title} ({self.post_time})" + def save(self, *args, **kwargs): self.title = truncate_string( - "{} – {}".format(self.track.artist.name, self.track.name), + f"{self.track.artist.name} – {self.track.name}", chars=255, truncate="…", at_word_boundary=True, @@ -331,12 +323,12 @@ class Track(TimeStampedModelMixin, models.Model): objects = managers.TracksManager() - def __str__(self): - return self.name - class Meta: ordering = ["name"] + def __str__(self): + return self.name + def get_absolute_url(self): "The track's URL locally." return reverse( @@ -347,7 +339,7 @@ def get_absolute_url(self): @property def permalink(self): "The Track's URL at Last.fm." - return "%s/music/%s/_/%s" % ( + return "{}/music/{}/_/{}".format( LASTFM_URL_ROOT, self.artist.original_slug, self.original_slug, @@ -356,7 +348,7 @@ def permalink(self): @property def musicbrainz_url(self): if self.mbid: - return "%s/recording/%s" % (MUSICBRAINZ_URL_ROOT, self.mbid) + return f"{MUSICBRAINZ_URL_ROOT}/recording/{self.mbid}" else: return None diff --git a/ditto/lastfm/templatetags/ditto_lastfm.py b/ditto/lastfm/templatetags/ditto_lastfm.py index 32738e0..1b13f11 100644 --- a/ditto/lastfm/templatetags/ditto_lastfm.py +++ b/ditto/lastfm/templatetags/ditto_lastfm.py @@ -4,8 +4,8 @@ from django import template from django.conf import settings -from ...core.utils import get_annual_item_counts -from ..models import Account, Album, Artist, Scrobble, Track +from ditto.core.utils import get_annual_item_counts +from ditto.lastfm.models import Account, Album, Artist, Scrobble, Track register = template.Library() @@ -27,7 +27,8 @@ def check_top_kwargs(**kwargs): ) if limit != "all" and isinstance(limit, int) is False: - raise ValueError("`limit` must be an integer or 'all'") + msg = "`limit` must be an integer or 'all'" + raise ValueError(msg) if ( date is not None @@ -60,10 +61,10 @@ def get_period_times(date, period): # `date` is a datetime.date min_time = datetime.datetime.combine( date, datetime.datetime.min.time() - ).replace(tzinfo=datetime.timezone.utc) + ).replace(tzinfo=datetime.UTC) max_time = datetime.datetime.combine( date, datetime.datetime.max.time() - ).replace(tzinfo=datetime.timezone.utc) + ).replace(tzinfo=datetime.UTC) if period == "week": # Default is Sunday (0): @@ -116,9 +117,7 @@ def top_albums(account=None, artist=None, limit=10, date=None, period="day"): period -- A String: 'day', 'week', 'month', or 'year'. """ - check_top_kwargs( - **{"account": account, "limit": limit, "date": date, "period": period} - ) + check_top_kwargs(account=account, limit=limit, date=date, period=period) if artist is not None and not isinstance(artist, Artist): raise TypeError("artist must be an Artist instance, " "not a %s" % type(artist)) @@ -161,9 +160,7 @@ def top_artists(account=None, limit=10, date=None, period="day"): date -- A datetime or date, for getting Artists from a single time period. period -- A String: 'day', 'week', 'month', or 'year'. """ - check_top_kwargs( - **{"account": account, "limit": limit, "date": date, "period": period} - ) + check_top_kwargs(account=account, limit=limit, date=date, period=period) qs_kwargs = {} @@ -208,9 +205,7 @@ def top_tracks( period -- A String: 'day', 'week', 'month', or 'year'. """ - check_top_kwargs( - **{"account": account, "limit": limit, "date": date, "period": period} - ) + check_top_kwargs(account=account, limit=limit, date=date, period=period) if album is not None and type(album) is not Album: raise TypeError("album must be an Album instance, " "not a %s" % type(album)) @@ -257,7 +252,8 @@ def recent_scrobbles(account=None, limit=10): ) if isinstance(limit, int) is False: - raise ValueError("`limit` must be an integer") + msg = "`limit` must be an integer" + raise ValueError(msg) if type(account) is Account: return account.get_recent_scrobbles(limit) diff --git a/ditto/lastfm/urls.py b/ditto/lastfm/urls.py index 8b916b4..079b868 100644 --- a/ditto/lastfm/urls.py +++ b/ditto/lastfm/urls.py @@ -6,7 +6,7 @@ # The pattern for matching an Album/Artist/Track slug: -slug_chars = r"[\w.,:;=@&+%()$!°’~-]+" # noqa: W605 +slug_chars = r"[\w.,:;=@&+%()$!°’~-]+" urlpatterns = [ @@ -38,12 +38,12 @@ name="artist_albums", ), re_path( - r"^music/(?P%s)/(?P%s)/$" % (slug_chars, slug_chars), + rf"^music/(?P{slug_chars})/(?P{slug_chars})/$", view=views.AlbumDetailView.as_view(), name="album_detail", ), re_path( - r"^music/(?P%s)/_/(?P%s)/$" % (slug_chars, slug_chars), + rf"^music/(?P{slug_chars})/_/(?P{slug_chars})/$", view=views.TrackDetailView.as_view(), name="track_detail", ), diff --git a/ditto/lastfm/views.py b/ditto/lastfm/views.py index 1499018..2e7052f 100644 --- a/ditto/lastfm/views.py +++ b/ditto/lastfm/views.py @@ -5,12 +5,13 @@ from django.views.generic import DetailView, TemplateView from django.views.generic.detail import SingleObjectMixin -from ..core.utils import datetime_now -from ..core.views import PaginatedListView +from ditto.core.utils import datetime_now +from ditto.core.views import PaginatedListView + from .models import Account, Album, Artist, Scrobble, Track -class AccountsMixin(object): +class AccountsMixin: """ View Mixin for adding an `account_list` to context, with all Accounts in. And the total counts of Scrobbles, Albums, Artists and Tracks. @@ -30,17 +31,19 @@ def get_context_data(self, **kwargs): class HomeView(AccountsMixin, TemplateView): "Uses template tags to display charts, recent Scrobbles, etc." + template_name = "lastfm/home.html" class ScrobbleListView(AccountsMixin, PaginatedListView): "A multi-page list of Scrobbles, most recent first." + template_name = "lastfm/scrobble_list.html" model = Scrobble def get_queryset(self): "Pre-fetch Artists and Tracks to reduce number of queries." - qs = super(ScrobbleListView, self).get_queryset() + qs = super().get_queryset() return qs.prefetch_related("artist", "track") @@ -124,24 +127,28 @@ def get_days(self): class AlbumListView(AccountsMixin, ChartPaginatedListView): "A multi-page chart of most-scrobbled Tracks." + template_name = "lastfm/album_list.html" model = Album class ArtistListView(AccountsMixin, ChartPaginatedListView): "A multi-page chart of most-scrobbled Tracks." + template_name = "lastfm/artist_list.html" model = Artist class TrackListView(AccountsMixin, ChartPaginatedListView): "A multi-page chart of most-scrobbled Tracks." + template_name = "lastfm/track_list.html" model = Track class AlbumDetailView(DetailView): "A single Album by a particular Artist." + model = Album def get_object(self, queryset=None): @@ -159,9 +166,8 @@ def get_object(self, queryset=None): album_slug = self.kwargs.get("album_slug") if artist_slug is None or album_slug is None: - raise AttributeError( - "AlbumDetailView must be called with " "artist_slug and album_slug" - ) + msg = "AlbumDetailView must be called with " "artist_slug and album_slug" + raise AttributeError(msg) artist = Artist.objects.get(slug=artist_slug) queryset = queryset.filter(artist=artist, slug=album_slug) @@ -169,22 +175,24 @@ def get_object(self, queryset=None): try: # Get the single item from the filtered queryset obj = queryset.get() - except queryset.model.DoesNotExist: + except queryset.model.DoesNotExist as err: raise Http404( _("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name} - ) + ) from err return obj class ArtistDetailView(DetailView): "One Artist. Uses a template tag to display a chart of their Tracks." + model = Artist slug_url_kwarg = "artist_slug" class ArtistAlbumsView(DetailView): "One Artist. Uses a template tag to display a chart of their Albums." + model = Artist slug_url_kwarg = "artist_slug" template_name = "lastfm/artist_albums.html" @@ -192,6 +200,7 @@ class ArtistAlbumsView(DetailView): class TrackDetailView(DetailView): "One Track by a particular Artist." + model = Track def get_object(self, queryset=None): @@ -209,9 +218,8 @@ def get_object(self, queryset=None): track_slug = self.kwargs.get("track_slug") if artist_slug is None or track_slug is None: - raise AttributeError( - "TrackDetailView must be called with " "artist_slug and track_slug" - ) + msg = "TrackDetailView must be called with " "artist_slug and track_slug" + raise AttributeError(msg) artist = Artist.objects.get(slug=artist_slug) queryset = queryset.filter(artist=artist, slug=track_slug) @@ -219,11 +227,11 @@ def get_object(self, queryset=None): try: # Get the single item from the filtered queryset obj = queryset.get() - except queryset.model.DoesNotExist: + except queryset.model.DoesNotExist as err: raise Http404( _("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name} - ) + ) from err return obj @@ -259,12 +267,14 @@ def get_context_data(self, **kwargs): class UserDetailView(SingleAccountMixin, DetailView): "Overview of the user; top 10s of everything." + template_name = "lastfm/user_detail.html" model = Account class UserAlbumListView(SingleAccountMixin, ChartPaginatedListView): "Chart of Albums scrobbled by one user." + template_name = "lastfm/user_album_list.html" model = Album @@ -279,6 +289,7 @@ def get_queryset(self): class UserArtistListView(SingleAccountMixin, ChartPaginatedListView): "Chart of Artists scrobbled by one user." + template_name = "lastfm/user_artist_list.html" model = Artist @@ -293,6 +304,7 @@ def get_queryset(self): class UserScrobbleListView(SingleAccountMixin, PaginatedListView): "All scrobbles by one user." + template_name = "lastfm/user_scrobble_list.html" model = Scrobble @@ -312,6 +324,7 @@ def get_context_data(self, **kwargs): class UserTrackListView(SingleAccountMixin, ChartPaginatedListView): "Chart of Tracks scrobbled by one user." + template_name = "lastfm/user_track_list.html" model = Track diff --git a/ditto/pinboard/admin.py b/ditto/pinboard/admin.py index 8ab0c79..963988b 100644 --- a/ditto/pinboard/admin.py +++ b/ditto/pinboard/admin.py @@ -4,7 +4,8 @@ from taggit.forms import TagWidget from taggit.managers import TaggableManager -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Bookmark diff --git a/ditto/pinboard/checks.py b/ditto/pinboard/checks.py index 84a1506..b0c23ca 100644 --- a/ditto/pinboard/checks.py +++ b/ditto/pinboard/checks.py @@ -17,14 +17,13 @@ def check_taggit_is_installed(app_configs=None, **kwargs): ) ) - if len(checks) == 0: - if "taggit" not in settings.INSTALLED_APPS: - checks.append( - Error( - "The django-taggit app must be in INSTALLED_APPS", - hint=("Add 'taggit' to INSTALLED_APPS " "in your settings file."), - id="ditto.pinboard.E002", - ) + if len(checks) == 0 and "taggit" not in settings.INSTALLED_APPS: + checks.append( + Error( + "The django-taggit app must be in INSTALLED_APPS", + hint=("Add 'taggit' to INSTALLED_APPS " "in your settings file."), + id="ditto.pinboard.E002", ) + ) return checks diff --git a/ditto/pinboard/factories.py b/ditto/pinboard/factories.py index 3d80af1..f1abfc8 100644 --- a/ditto/pinboard/factories.py +++ b/ditto/pinboard/factories.py @@ -2,7 +2,8 @@ import factory -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models diff --git a/ditto/pinboard/fetch.py b/ditto/pinboard/fetch.py index 54ec32a..6aae976 100644 --- a/ditto/pinboard/fetch.py +++ b/ditto/pinboard/fetch.py @@ -1,11 +1,11 @@ -# coding: utf-8 import json import urllib -from datetime import datetime, timezone +from datetime import UTC, datetime import requests -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from .models import Account, Bookmark PINBOARD_API_ENDPOINT = "https://api.pinboard.in/v1/" @@ -18,17 +18,16 @@ class FetchError(Exception): pass -class BookmarksFetcher(object): +class BookmarksFetcher: """The parent class containing common methods. Use one of the child classes to fetch a particular set of Bookmarks. """ def fetch(self): - raise FetchError( - "Call a child class like AllBookmarksFetcher or RecentBookmarksFetcher" - ) + msg = "Call a child class like AllBookmarksFetcher or RecentBookmarksFetcher" + raise FetchError(msg) - def _fetch(self, fetch_type, params={}, username=None): + def _fetch(self, fetch_type, params=None, username=None): """The main method for making all types of Bookmark requests, and saving the data. @@ -38,6 +37,7 @@ def _fetch(self, fetch_type, params={}, username=None): These will be used directly with the Pinboard API. username -- the username of the one Account to fetch (or None for all). """ + params = {} if params is None else params # Each element will be a dict, like: # {'account':'philgyford', 'success':True, 'fetched':12} result = [] @@ -78,7 +78,8 @@ def _get_accounts(self, username): if username is None: accounts = Account.objects.filter(is_active=True) if len(accounts) == 0: - raise FetchError("No active accounts were found to fetch.") + msg = "No active accounts were found to fetch." + raise FetchError(msg) else: account = Account.objects.get(username=username) if account.is_active: @@ -116,7 +117,7 @@ def _send_request(self, fetch_type, params, account): params["auth_token"] = account.api_token query_string = urllib.parse.urlencode(params) - final_url = "{}?{}".format(url, query_string) + final_url = f"{url}?{query_string}" error_message = "" @@ -167,10 +168,7 @@ def _parse_response(self, fetch_type, json_text): response = json.loads(json_text) # The JSON has different formats depending on what we fetched: - if fetch_type == "all": - posts = response - else: - posts = response["posts"] + posts = response if fetch_type == "all" else response["posts"] for bookmark in posts: # Before we do anything to it, we give the bookmark a 'json' @@ -179,10 +177,10 @@ def _parse_response(self, fetch_type, json_text): # Time string to object: bookmark["time"] = datetime.strptime( bookmark["time"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) + ).replace(tzinfo=UTC) # 'yes'/'no' to booleans: - bookmark["shared"] = True if bookmark["shared"] == "yes" else False - bookmark["toread"] = True if bookmark["toread"] == "yes" else False + bookmark["shared"] = bookmark["shared"] == "yes" + bookmark["toread"] = bookmark["toread"] == "yes" # String into an array of strings: bookmark["tags"] = bookmark["tags"].split() @@ -251,9 +249,9 @@ def fetch(self, post_date, username=None): FetchError if the date format is invalid. """ try: - dt = datetime.strptime(post_date, "%Y-%m-%d") - except ValueError: - raise FetchError("Invalid date format ('%s')" % post_date) + dt = datetime.strptime(post_date, "%Y-%m-%d").astimezone(UTC) + except ValueError as err: + raise FetchError("Invalid date format ('%s')" % post_date) from err else: return self._fetch(fetch_type="date", params={"dt": dt}, username=username) diff --git a/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py b/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py index 53aeac8..887a7bf 100644 --- a/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py +++ b/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py @@ -1,8 +1,7 @@ -# coding: utf-8 from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand -from ...fetch import ( +from ditto.core.management.commands import DittoBaseCommand +from ditto.pinboard.fetch import ( AllBookmarksFetcher, DateBookmarksFetcher, RecentBookmarksFetcher, @@ -66,7 +65,6 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - # We might be fetching for a specific account or all (None). account = options["account"] if options["account"] else None @@ -87,10 +85,10 @@ def handle(self, *args, **options): results = UrlBookmarksFetcher().fetch(url=options["url"], username=account) elif options["account"]: - raise CommandError( - "Specify --all, --recent, --date= or --url= as well as --account." - ) + msg = "Specify --all, --recent, --date= or --url= as well as --account." + raise CommandError(msg) else: - raise CommandError("Specify --all, --recent, --date= or --url=") + msg = "Specify --all, --recent, --date= or --url=" + raise CommandError(msg) self.output_results(results, options.get("verbosity", 1)) diff --git a/ditto/pinboard/models.py b/ditto/pinboard/models.py index 22bc7b0..0040da6 100644 --- a/ditto/pinboard/models.py +++ b/ditto/pinboard/models.py @@ -1,4 +1,3 @@ -# coding: utf-8 import hashlib from django.core.validators import URLValidator @@ -6,7 +5,8 @@ from taggit.managers import TaggableManager from taggit.models import GenericTaggedItemBase, TagBase -from ..core.models import DittoItemModel, TimeStampedModelMixin +from ditto.core.models import DittoItemModel, TimeStampedModelMixin + from .managers import PublicToreadManager, ToreadManager, _BookmarkTaggableManager @@ -42,12 +42,12 @@ class Account(TimeStampedModelMixin, models.Model): help_text="If false, new Bookmarks won't be fetched.", ) - def __str__(self): - return self.username - class Meta: ordering = ["username"] + def __str__(self): + return self.username + def get_absolute_url(self): from django.urls import reverse @@ -141,7 +141,6 @@ class TaggedBookmark(TimeStampedModelMixin, GenericTaggedItemBase): class Bookmark(DittoItemModel, ExtraBookmarkManagers): - ditto_item_name = "pinboard_bookmark" # Properties inherited from DittoItemModel: @@ -224,7 +223,7 @@ def get_next_public_by_post_time(self): .order_by("post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass def get_previous_public_by_post_time(self): @@ -238,7 +237,7 @@ def get_previous_public_by_post_time(self): .order_by("-post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass # Shortcuts: diff --git a/ditto/pinboard/templatetags/ditto_pinboard.py b/ditto/pinboard/templatetags/ditto_pinboard.py index 3ebbd53..96698cd 100644 --- a/ditto/pinboard/templatetags/ditto_pinboard.py +++ b/ditto/pinboard/templatetags/ditto_pinboard.py @@ -1,9 +1,9 @@ -from datetime import datetime, time, timezone +from datetime import UTC, datetime, time from django import template -from ...core.utils import get_annual_item_counts -from ..models import Bookmark +from ditto.core.utils import get_annual_item_counts +from ditto.pinboard.models import Bookmark register = template.Library() @@ -34,8 +34,8 @@ def day_bookmarks(date, account=None): Keyword arguments: account -- An account username, 'philgyford', or None to fetch for all. """ - start = datetime.combine(date, time.min).replace(tzinfo=timezone.utc) - end = datetime.combine(date, time.max).replace(tzinfo=timezone.utc) + start = datetime.combine(date, time.min).replace(tzinfo=UTC) + end = datetime.combine(date, time.max).replace(tzinfo=UTC) bookmarks = Bookmark.public_objects.filter(post_time__range=[start, end]) if account is not None: bookmarks = bookmarks.filter(account__username=account) diff --git a/ditto/pinboard/views.py b/ditto/pinboard/views.py index 9b02158..575e5ab 100644 --- a/ditto/pinboard/views.py +++ b/ditto/pinboard/views.py @@ -3,12 +3,14 @@ from django.views.generic import DetailView, ListView from django.views.generic.detail import SingleObjectMixin -from ..core.views import PaginatedListView +from ditto.core.views import PaginatedListView + from .models import Account, Bookmark, BookmarkTag class SingleAccountMixin(SingleObjectMixin): "For views which list bookmarks and also need an Account object." + slug_field = "username" slug_url_kwarg = "username" @@ -24,6 +26,7 @@ def get_context_data(self, **kwargs): class HomeView(PaginatedListView): "List all recent Bookmarks and all Accounts" + template_name = "pinboard/home.html" queryset = Bookmark.public_objects.all().prefetch_related("account") @@ -45,6 +48,7 @@ def get_context_data(self, **kwargs): class AccountDetailView(SingleAccountMixin, PaginatedListView): "A single Pinboard Account and its Bookmarks." + template_name = "pinboard/account_detail.html" def get_context_data(self, **kwargs): @@ -61,6 +65,7 @@ def get_queryset(self): class AccountToreadView(SingleAccountMixin, PaginatedListView): "A single Pinboard Account and its 'to read' Bookmarks." + template_name = "pinboard/account_toread.html" def get_context_data(self, **kwargs): @@ -77,6 +82,7 @@ def get_queryset(self): class BookmarkDetailView(DetailView): "A single Bookmark, from one Account" + model = Bookmark # Only display public bookmarks; private ones will 404. queryset = Bookmark.public_objects.all() @@ -99,6 +105,7 @@ def get_context_data(self, **kwargs): class TagDetailView(SingleObjectMixin, PaginatedListView): "All Bookmarks with a certain tag from all Accounts" + template_name = "pinboard/tag_detail.html" allow_empty = False @@ -122,6 +129,7 @@ def get_queryset(self): class AccountTagDetailView(SingleAccountMixin, PaginatedListView): "All Bookmarks with a certain Tag from one Account" + template_name = "pinboard/account_tag_detail.html" allow_empty = False @@ -133,8 +141,8 @@ def get_tag_object(self): """Custom method for fetching the Tag.""" try: obj = BookmarkTag.objects.get(slug=self.kwargs["tag_slug"]) - except BookmarkTag.DoesNotExist: - raise Http404(_("No Tags found matching the query")) + except BookmarkTag.DoesNotExist as err: + raise Http404(_("No Tags found matching the query")) from err return obj def get_context_data(self, **kwargs): diff --git a/ditto/scripts/flickr_authorize.py b/ditto/scripts/flickr_authorize.py index 5fe84f8..2e82375 100644 --- a/ditto/scripts/flickr_authorize.py +++ b/ditto/scripts/flickr_authorize.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# ruff: noqa: T201 import flickrapi # Put your API Key and Secret here: @@ -9,7 +10,6 @@ # Only do this if we don't have a valid token already if not flickr.token_valid(perms="read"): - # Get a request token flickr.get_request_token(oauth_callback="oob") diff --git a/ditto/twitter/admin.py b/ditto/twitter/admin.py index e2dcdaf..089cb29 100644 --- a/ditto/twitter/admin.py +++ b/ditto/twitter/admin.py @@ -3,7 +3,8 @@ from django.forms import Textarea, TextInput from django.utils.safestring import mark_safe -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Media, Tweet, User @@ -77,7 +78,6 @@ class TweetsMediaInline(admin.TabularInline): @admin.register(Media) class MediaAdmin(admin.ModelAdmin): - list_display = ( "show_thumb", "media_type", @@ -155,33 +155,29 @@ class MediaAdmin(admin.ModelAdmin): def show_thumb(self, instance): return mark_safe( - '' - % { - "url": instance.thumbnail_url, - "w": instance.thumbnail_w, - "h": instance.thumbnail_h, - } + ''.format( + instance.thumbnail_url, + instance.thumbnail_w, + instance.thumbnail_h, + ) ) show_thumb.short_description = "" def show_image(self, instance): if instance.media_type == "photo": - html = '' % { - "url": instance.small_url, - "w": instance.small_w, - "h": instance.small_h, - } + html = ''.format( + instance.small_url, + instance.small_w, + instance.small_h, + ) else: - html = ( - '' # noqa: E501 - % { - "w": instance.small_w, - "h": instance.small_h, - "img": instance.small_url, - "video": instance.video_url, - "mime": instance.video_mime_type, - } + html = ''.format( # noqa: E501 + instance.small_w, + instance.small_h, + instance.small_url, + instance.video_url, + instance.video_mime_type, ) return mark_safe(html) diff --git a/ditto/twitter/factories.py b/ditto/twitter/factories.py index a78a874..d931913 100644 --- a/ditto/twitter/factories.py +++ b/ditto/twitter/factories.py @@ -2,7 +2,8 @@ import factory -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models @@ -92,7 +93,6 @@ def tweets(self, create, extracted, **kwargs): class PhotoFactory(MediaFactory): - media_type = "photo" large_w = 938 @@ -109,7 +109,6 @@ class PhotoFactory(MediaFactory): class VideoFactory(MediaFactory): - media_type = "video" image_url = factory.Sequence( @@ -130,7 +129,6 @@ class VideoFactory(MediaFactory): class AnimatedGifFactory(MediaFactory): - media_type = "animated_gif" image_url = factory.Sequence( diff --git a/ditto/twitter/fetch/fetch.py b/ditto/twitter/fetch/fetch.py index 54fe351..09cc66f 100644 --- a/ditto/twitter/fetch/fetch.py +++ b/ditto/twitter/fetch/fetch.py @@ -4,9 +4,10 @@ from django.core.files import File from twython import Twython, TwythonError -from ...core.utils import datetime_now -from ...core.utils.downloader import DownloadException, filedownloader -from ..models import Media, Tweet, User +from ditto.core.utils import datetime_now +from ditto.core.utils.downloader import DownloadException, filedownloader +from ditto.twitter.models import Media, Tweet, User + from . import FetchError from .savers import TweetSaver, UserSaver @@ -28,7 +29,7 @@ # FetchFiles -class Fetch(object): +class Fetch: """Parent class for children that will call the Twitter API to fetch data for a single Account. @@ -128,9 +129,8 @@ def _call_api(self): Should call self.api.a_function_name() and set self.results with the results. """ - raise FetchError( - "Children of the Fetch class should define their own _call_api() method." - ) + msg = "Children of the Fetch class should define their own _call_api() method." + raise FetchError(msg) def _save_results(self): """Define in child classes. @@ -143,13 +143,11 @@ def _post_save(self): """Can optionally be defined in child classes. Do any extra things that need to be done after saving a page of data. """ - pass def _post_fetch(self): """Can optionally be defined in child classes. Do any extra things that need to be done after we've fetched all data. """ - pass class FetchVerify(Fetch): @@ -189,7 +187,7 @@ class FetchLookup(Fetch): # Maxmum number of requests allowed per 15 minute window: max_requests = 60 - def fetch(self, ids=[]): + def fetch(self, ids=None): """ Keyword arguments: ids -- A list of Twitter user/tweet IDs to fetch. Optional. If not @@ -198,6 +196,7 @@ def fetch(self, ids=[]): allows 100 per query, and 60 queries per 15 minute window. So 6000 ids would be the maximum. """ + ids = [] if ids is None else ids self._set_initial_ids(ids) return super().fetch() @@ -228,10 +227,7 @@ def _post_save(self): self._fetch_pages() def _more_to_fetch(self): - if len(self.ids_remaining_to_fetch) > 0: - return True - else: - return False + return len(self.ids_remaining_to_fetch) > 0 def _ids_to_fetch_in_query(self): """ @@ -343,15 +339,9 @@ def _post_save(self): def _more_to_fetch(self): if self.fetch_type == "new": - if self._since_id() is None or self.max_id > self._since_id(): - return True - else: - return False + return self._since_id() is None or self.max_id > self._since_id() elif self.fetch_type == "number": - if self.remaining_to_fetch > 0: - return True - else: - return False + return self.remaining_to_fetch > 0 return False def _tweets_to_fetch_in_query(self): @@ -450,7 +440,7 @@ def _save_results(self): self.objects.append(tw) -class FetchFiles(object): +class FetchFiles: """ For fetching image files and Animated GIFs' MP4 files. @@ -475,7 +465,7 @@ class FetchFiles(object): # When fetching Tweets or Users this will be the total amount fetched. results_count = 0 - def fetch(self, fetch_all=False): + def fetch(self, *, fetch_all=False): """ Download and save original images for all Media objects (or just those that don't already have them). @@ -551,19 +541,20 @@ def _fetch_and_save_file(self, media_obj, media_type): "image/gif", ] else: - raise FetchError('media_type should be "image" or "mp4"') + msg = 'media_type should be "image" or "mp4"' + raise FetchError(msg) filepath = False try: # Saves the file to /tmp/: filepath = filedownloader.download(url, acceptable_content_types) - except DownloadException as e: - raise FetchError(e) + except DownloadException as err: + raise FetchError(err) from err if filepath: # Reopen file and save to the Media object: - reopened_file = open(filepath, "rb") - django_file = File(reopened_file) + with open(filepath, "rb") as reopened_file: + django_file = File(reopened_file) if media_type == "mp4": media_obj.mp4_file.save(os.path.basename(filepath), django_file) diff --git a/ditto/twitter/fetch/fetchers.py b/ditto/twitter/fetch/fetchers.py index da0fb4e..35cc184 100644 --- a/ditto/twitter/fetch/fetchers.py +++ b/ditto/twitter/fetch/fetchers.py @@ -1,4 +1,5 @@ -from ..models import Account +from ditto.twitter.models import Account + from . import FetchError from .fetch import ( Fetch, @@ -31,7 +32,7 @@ # FilesFetcher -class TwitterFetcher(object): +class TwitterFetcher: """Parent class for children that will call the Twitter API to fetch data for one or several Accounts. @@ -70,8 +71,8 @@ def fetch(self, **kwargs): success/failure. """ for account in self.accounts: - accountFetcher = self._get_account_fetcher(account) - return_value = accountFetcher.fetch(**kwargs) + account_fetcher = self._get_account_fetcher(account) + return_value = account_fetcher.fetch(**kwargs) self._add_to_return_values(return_value) return self.return_values @@ -102,15 +103,16 @@ def _set_accounts(self, screen_name=None): if screen_name is None: accounts = Account.objects.filter(is_active=True) if len(accounts) == 0: - raise FetchError("No active Accounts were found to fetch.") + msg = "No active Accounts were found to fetch." + raise FetchError(msg) else: try: accounts = [Account.objects.get(user__screen_name=screen_name)] - except Account.DoesNotExist: + except Account.DoesNotExist as err: raise FetchError( "There is no Account in the database with a screen_name of '%s'" % screen_name - ) + ) from err else: if accounts[0].is_active is False: raise FetchError( @@ -150,11 +152,12 @@ class UsersFetcher(TwitterFetcher): results = fetcher.fetch(ids=[123456,9876,]) """ - def fetch(self, ids=[]): + def fetch(self, ids=None): """ Keyword arguments: ids -- A list of Twitter user IDs to fetch and store data for. """ + ids = [] if ids is None else ids return super().fetch(ids=ids) def _get_account_fetcher(self, account): @@ -172,11 +175,12 @@ class TweetsFetcher(TwitterFetcher): results = fetcher.fetch(ids=[123456,9876,]) """ - def fetch(self, ids=[]): + def fetch(self, ids=None): """ Keyword arguments: ids -- A list of Twitter Tweet IDs to fetch and store data for. """ + ids = [] if ids is None else ids return super().fetch(ids=ids) def _get_account_fetcher(self, account): @@ -230,7 +234,7 @@ def _get_account_fetcher(self, account): return FetchTweetsFavorite(account) -class FilesFetcher(object): +class FilesFetcher: """ Use like: fetcher = FilesFetcher() @@ -245,7 +249,7 @@ class FilesFetcher(object): def __init__(self): self.return_values = [] - def fetch(self, fetch_all=False): + def fetch(self, *, fetch_all=False): results = FetchFiles().fetch(fetch_all=fetch_all) # Return a list to behave similar to the other *Fetcher() classes that diff --git a/ditto/twitter/fetch/savers.py b/ditto/twitter/fetch/savers.py index 693be16..2550c29 100644 --- a/ditto/twitter/fetch/savers.py +++ b/ditto/twitter/fetch/savers.py @@ -1,13 +1,13 @@ import json import os -from datetime import datetime, timezone +from datetime import UTC, datetime from django.conf import settings from django.core.files import File -from ...core.utils import truncate_string -from ...core.utils.downloader import DownloadException, filedownloader -from ..models import Media, Tweet, User +from ditto.core.utils import truncate_string +from ditto.core.utils.downloader import DownloadException, filedownloader +from ditto.twitter.models import Media, Tweet, User # Classes that take JSON data from the Twitter API and create or update # objects. @@ -21,7 +21,7 @@ # TweetSaver -class SaveUtilsMixin(object): +class SaveUtilsMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -29,16 +29,16 @@ def _api_time_to_datetime(self, api_time, time_format="%a %b %d %H:%M:%S +0000 % """Change a text datetime from the API to a datetime with timezone. api_time is a string like 'Wed Nov 15 16:55:59 +0000 2006'. """ - return datetime.strptime(api_time, time_format).replace(tzinfo=timezone.utc) + return datetime.strptime(api_time, time_format).replace(tzinfo=UTC) -class UserSaver(SaveUtilsMixin, object): +class UserSaver(SaveUtilsMixin): "Provides a method for creating/updating a User using data from the API." def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def save_user(self, user, fetch_time, download_avatar=True): + def save_user(self, user, fetch_time, *, download_avatar=True): """With Twitter user data from the API, it creates or updates the User and returns the User object. @@ -135,16 +135,15 @@ def _fetch_and_save_avatar(self, user): ["image/jpeg", "image/jpg", "image/png", "image/gif"], ) - user.avatar.save( - os.path.basename(avatar_filepath), File(open(avatar_filepath, "rb")) - ) + with open(avatar_filepath, "rb") as f: + user.avatar.save(os.path.basename(avatar_filepath), File(f)) except DownloadException: pass return user -class TweetSaver(SaveUtilsMixin, object): +class TweetSaver(SaveUtilsMixin): """Provides a method for creating/updating a Tweet (and its User) using data from the API. Also used by ingest.TweetIngester() """ @@ -202,11 +201,10 @@ def save_media(self, tweet): # Adding things ony used for videos and animated GIFs: if "video_info" in item: - info = item["video_info"] # eg, '16:9' - defaults["aspect_ratio"] = "%s:%s" % ( + defaults["aspect_ratio"] = "{}:{}".format( info["aspect_ratio"][0], info["aspect_ratio"][1], ) @@ -289,7 +287,8 @@ def save_tweet(self, tweet, fetch_time, user_data=None): if "user" in tweet: user_data = tweet["user"] else: - raise ValueError("No user data found to save tweets with") + msg = "No user data found to save tweets with" + raise ValueError(msg) user = UserSaver().save_user(user_data, fetch_time) @@ -317,8 +316,9 @@ def save_tweet(self, tweet, fetch_time, user_data=None): "user": user, "is_private": user.is_private, "post_time": created_at, - "permalink": "https://twitter.com/%s/status/%s" - % (user.screen_name, tweet["id"]), + "permalink": "https://twitter.com/{}/status/{}".format( + user.screen_name, tweet["id"] + ), "title": title.replace("\n", " ").replace("\r", " "), "text": text, "twitter_id": tweet["id"], @@ -338,7 +338,7 @@ def save_tweet(self, tweet, fetch_time, user_data=None): if "lang" in tweet: defaults["language"] = tweet["lang"] - if ( + if ( # noqa: SIM102 "coordinates" in tweet and tweet["coordinates"] and "type" in tweet["coordinates"] diff --git a/ditto/twitter/ingest.py b/ditto/twitter/ingest.py index 919a728..48b2b14 100644 --- a/ditto/twitter/ingest.py +++ b/ditto/twitter/ingest.py @@ -4,7 +4,8 @@ from django.core.files import File -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from .fetch.savers import TweetSaver from .models import Media @@ -13,7 +14,7 @@ class IngestError(Exception): pass -class TweetIngester(object): +class TweetIngester: """For importing a downloaded archive of tweets. Request yours from https://twitter.com/settings/account @@ -83,10 +84,11 @@ def _load_data(self, directory): And it should set self.file_count to be the number of JS files we import the data from. """ - raise NotImplementedError( + msg = ( "Child classes of TweetImporter must implement their own " "_load_data() method." ) + raise NotImplementedError(msg) def _save_tweets(self, directory): """Go through the list of dicts that is self.tweets_data and @@ -103,7 +105,6 @@ def _save_media(self, directory): """Save media files. Not doing anything by default. """ - pass class Version1TweetIngester(TweetIngester): @@ -134,11 +135,11 @@ def _load_data(self, directory): try: for file in os.listdir(directory): if file.endswith(".js"): - filepath = "%s/%s" % (directory, file) + filepath = f"{directory}/{file}" self._get_data_from_file(filepath) self.file_count += 1 - except OSError as e: - raise IngestError(e) + except OSError as err: + raise IngestError(err) from err if self.file_count == 0: raise IngestError("No .js files found in %s" % directory) @@ -150,17 +151,17 @@ def _get_data_from_file(self, filepath): Arguments: filespath -- Absolute path to the file. """ - f = open(filepath, "r") - lines = f.readlines() - # Remove first line, that contains JavaScript: - lines = lines[1:] - try: - tweets_data = json.loads("".join(lines)) - except ValueError as e: - raise IngestError(f"Could not load JSON from {filepath}: {e}") - else: - self.tweets_data.extend(tweets_data) - f.close() + with open(filepath) as f: + lines = f.readlines() + # Remove first line, that contains JavaScript: + lines = lines[1:] + try: + tweets_data = json.loads("".join(lines)) + except ValueError as err: + msg = f"Could not load JSON from {filepath}: {err}" + raise IngestError(msg) from err + else: + self.tweets_data.extend(tweets_data) class Version2TweetIngester(TweetIngester): @@ -249,17 +250,17 @@ def _save_media(self, directory): directory, "tweet_media", local_filename ) - django_file = File(open(filepath, "rb")) - - if media_obj.media_type == "animated_gif": - # When we fetch GIFs we also fetch an image file for - # them. But their images aren't included in the - # downloaded archive so we'll make do without here. - media_obj.mp4_file.save(filename, django_file) - self.media_count += 1 - elif media_obj.media_type == "photo": - media_obj.image_file.save(filename, django_file) - self.media_count += 1 + with File(open(filepath, "rb")) as django_file: + if media_obj.media_type == "animated_gif": + # When we fetch GIFs we also fetch an image file + # for them. But their images aren't included in + # the downloaded archive so we'll make do + # without here. + media_obj.mp4_file.save(filename, django_file) + self.media_count += 1 + elif media_obj.media_type == "photo": + media_obj.image_file.save(filename, django_file) + self.media_count += 1 def _construct_user_data(self, directory): """ @@ -289,17 +290,18 @@ def _construct_user_data(self, directory): "downloaded twitter archive by Django Ditto" ), } - except KeyError as e: - raise ImportError(f"Error creating user data: {e}") + except KeyError as err: + msg = f"Error creating user data: {err}" + raise ImportError(msg) from err return user_data def _get_json_from_file(self, directory, filepath): filepath = os.path.join(directory, filepath) try: - f = open(filepath) - except OSError as e: - raise ImportError(e) + f = open(filepath) # noqa: SIM115 + except OSError as err: + raise ImportError(err) from err lines = f.readlines() # Remove first line, that contains JavaScript: @@ -307,8 +309,9 @@ def _get_json_from_file(self, directory, filepath): try: data = json.loads("".join(lines)) - except ValueError as e: - raise IngestError(f"Could not load JSON from {filepath}: {e}") + except ValueError as err: + msg = f"Could not load JSON from {filepath}: {err}" + raise IngestError(msg) from err else: f.close() return data diff --git a/ditto/twitter/management/commands/__init__.py b/ditto/twitter/management/commands/__init__.py index b3b2d79..8c65de8 100644 --- a/ditto/twitter/management/commands/__init__.py +++ b/ditto/twitter/management/commands/__init__.py @@ -1,12 +1,10 @@ -# coding: utf-8 from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand -from ...models import Account +from ditto.core.management.commands import DittoBaseCommand +from ditto.twitter.models import Account class FetchTwitterCommand(DittoBaseCommand): - # What we're fetching: singular_noun = "Tweet" plural_noun = "Tweets" @@ -28,7 +26,6 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - # We might be fetching for a specific account or all (None). account = options["account"] if options["account"] else None @@ -40,9 +37,11 @@ def handle(self, *args, **options): results = self.fetch_tweets(account, options["recent"]) self.output_results(results, options.get("verbosity", 1)) elif options["account"]: - raise CommandError("Specify --recent as well as --account.") + msg = "Specify --recent as well as --account." + raise CommandError(msg) else: - raise CommandError("Specify --recent, eg --recent=100 or --recent=new.") + msg = "Specify --recent, eg --recent=100 or --recent=new." + raise CommandError(msg) def fetch_tweets(self, screen_name, count): """Child classes should override this method to call a method that @@ -73,12 +72,13 @@ def handle(self, *args, **options): screen_name = options["account"] try: Account.objects.get(user__screen_name=screen_name) - except Account.DoesNotExist: + except Account.DoesNotExist as err: raise CommandError( "There's no Account with a screen name of '%s'" % screen_name - ) + ) from err else: - raise CommandError("Specify --account, eg --account=philgyford.") + msg = "Specify --account, eg --account=philgyford." + raise CommandError(msg) results = self.fetch(screen_name) self.output_results(results, options.get("verbosity", 1)) diff --git a/ditto/twitter/management/commands/fetch_twitter_accounts.py b/ditto/twitter/management/commands/fetch_twitter_accounts.py index 9aff56b..a55d6eb 100644 --- a/ditto/twitter/management/commands/fetch_twitter_accounts.py +++ b/ditto/twitter/management/commands/fetch_twitter_accounts.py @@ -1,7 +1,6 @@ -# coding: utf-8 from django.core.management.base import BaseCommand -from ...fetch.fetchers import VerifyFetcher +from ditto.twitter.fetch.fetchers import VerifyFetcher class Command(BaseCommand): @@ -45,6 +44,7 @@ def handle(self, *args, **options): self.stdout.write("Fetched @%s" % result["account"]) else: self.stderr.write( - "Could not fetch @%s: %s" - % (result["account"], result["messages"][0]) + "Could not fetch @{}: {}".format( + result["account"], result["messages"][0] + ) ) diff --git a/ditto/twitter/management/commands/fetch_twitter_favorites.py b/ditto/twitter/management/commands/fetch_twitter_favorites.py index 9b5e78e..d852f1b 100644 --- a/ditto/twitter/management/commands/fetch_twitter_favorites.py +++ b/ditto/twitter/management/commands/fetch_twitter_favorites.py @@ -1,5 +1,5 @@ -# coding: utf-8 -from ...fetch.fetchers import FavoriteTweetsFetcher +from ditto.twitter.fetch.fetchers import FavoriteTweetsFetcher + from . import FetchTwitterCommand diff --git a/ditto/twitter/management/commands/fetch_twitter_files.py b/ditto/twitter/management/commands/fetch_twitter_files.py index 170ca47..efc2692 100644 --- a/ditto/twitter/management/commands/fetch_twitter_files.py +++ b/ditto/twitter/management/commands/fetch_twitter_files.py @@ -1,6 +1,5 @@ -# coding: utf-8 -from ....core.management.commands import DittoBaseCommand -from ...fetch.fetchers import FilesFetcher +from ditto.core.management.commands import DittoBaseCommand +from ditto.twitter.fetch.fetchers import FilesFetcher class Command(DittoBaseCommand): diff --git a/ditto/twitter/management/commands/fetch_twitter_tweets.py b/ditto/twitter/management/commands/fetch_twitter_tweets.py index 3f2ad27..ca16b6f 100644 --- a/ditto/twitter/management/commands/fetch_twitter_tweets.py +++ b/ditto/twitter/management/commands/fetch_twitter_tweets.py @@ -1,5 +1,5 @@ -# coding: utf-8 -from ...fetch.fetchers import RecentTweetsFetcher +from ditto.twitter.fetch.fetchers import RecentTweetsFetcher + from . import FetchTwitterCommand diff --git a/ditto/twitter/management/commands/generate_twitter_tweet_html.py b/ditto/twitter/management/commands/generate_twitter_tweet_html.py index a709077..d62cb6e 100644 --- a/ditto/twitter/management/commands/generate_twitter_tweet_html.py +++ b/ditto/twitter/management/commands/generate_twitter_tweet_html.py @@ -1,7 +1,6 @@ -# coding: utf-8 from django.core.management.base import BaseCommand, CommandError -from ...models import Account, Tweet +from ditto.twitter.models import Account, Tweet class Command(BaseCommand): @@ -33,10 +32,10 @@ def handle(self, *args, **options): screen_name = options["account"] try: Account.objects.get(user__screen_name=screen_name) - except Account.DoesNotExist: + except Account.DoesNotExist as err: raise CommandError( "There's no Account with a screen name of '%s'" % screen_name - ) + ) from err tweets = tweets.filter(user__screen_name=screen_name) for tweet in tweets: diff --git a/ditto/twitter/management/commands/import_twitter_tweets.py b/ditto/twitter/management/commands/import_twitter_tweets.py index b8c35cd..2090223 100644 --- a/ditto/twitter/management/commands/import_twitter_tweets.py +++ b/ditto/twitter/management/commands/import_twitter_tweets.py @@ -1,9 +1,8 @@ -# coding: utf-8 import os from django.core.management.base import BaseCommand, CommandError -from ...ingest import Version1TweetIngester, Version2TweetIngester +from ditto.twitter.ingest import Version1TweetIngester, Version2TweetIngester class Command(BaseCommand): @@ -11,7 +10,8 @@ class Command(BaseCommand): Request your archive from https://twitter.com/settings/account Usage: - ./manage.py import_tweets --path=/Users/phil/Downloads/12552_dbeb4be9b8ff5f76d7d486c005cc21c9faa61f66 # noqa: E501 + ./manage.py import_tweets \ + --path=/Users/phil/Downloads/12552_dbeb4be9b8ff5f76d7d486c005cc21c9faa61f66 """ help = "Imports a complete history of tweets from a downloaded archive" @@ -49,29 +49,29 @@ def handle(self, *args, **options): ingester_class = Version1TweetIngester elif options["archive_version"] != "v2": - raise CommandError( - f"version should be v1 or v2, not '{options['archive_version']}" - ) + msg = f"version should be v1 or v2, not '{options['archive_version']}" + raise CommandError(msg) if options["path"]: if os.path.isdir(options["path"]): - js_dir = "%s%s" % (options["path"], subpath) + js_dir = f"{options['path']}{subpath}" if os.path.isdir(js_dir): result = ingester_class().ingest(directory=js_dir) else: - raise CommandError( + msg = ( f"Expected to find a directory at '{js_dir}' " "containing .js file(s)" ) + raise CommandError(msg) else: - raise CommandError(f"Can't find a directory at '{options['path']}'") + msg = f"Can't find a directory at '{options['path']}'" + raise CommandError(msg) else: - raise CommandError( - ( - "Specify the location of the archive directory, " - "e.g. --path=/path/to/twitter-2022-01-31-abcdef123456" - ) + msg = ( + "Specify the location of the archive directory, " + "e.g. --path=/path/to/twitter-2022-01-31-abcdef123456" ) + raise CommandError(msg) if options.get("verbosity", 1) > 0: if result["success"]: @@ -85,5 +85,4 @@ def handle(self, *args, **options): f"and {result['media']} media {mediafilenoun}" ) else: - self.stderr.write(f"Failed to import tweets: {result['messages'][0]}") diff --git a/ditto/twitter/management/commands/update_twitter_tweets.py b/ditto/twitter/management/commands/update_twitter_tweets.py index 8168426..8b1bad6 100644 --- a/ditto/twitter/management/commands/update_twitter_tweets.py +++ b/ditto/twitter/management/commands/update_twitter_tweets.py @@ -1,4 +1,5 @@ -from ...fetch.fetchers import TweetsFetcher +from ditto.twitter.fetch.fetchers import TweetsFetcher + from . import UpdateTwitterCommand diff --git a/ditto/twitter/management/commands/update_twitter_users.py b/ditto/twitter/management/commands/update_twitter_users.py index b7070ee..f2ca79c 100644 --- a/ditto/twitter/management/commands/update_twitter_users.py +++ b/ditto/twitter/management/commands/update_twitter_users.py @@ -1,4 +1,5 @@ -from ...fetch.fetchers import UsersFetcher +from ditto.twitter.fetch.fetchers import UsersFetcher + from . import UpdateTwitterCommand diff --git a/ditto/twitter/managers.py b/ditto/twitter/managers.py index 4130097..79ee0b6 100644 --- a/ditto/twitter/managers.py +++ b/ditto/twitter/managers.py @@ -1,6 +1,6 @@ from django.db import models -from ..core.managers import PublicItemManager +from ditto.core.managers import PublicItemManager class PublicFavoritesManager(models.Manager): diff --git a/ditto/twitter/models.py b/ditto/twitter/models.py index 537fae9..bca1646 100644 --- a/ditto/twitter/models.py +++ b/ditto/twitter/models.py @@ -1,19 +1,15 @@ -# coding: utf-8 +import contextlib import json import logging import os -try: - from django.urls import reverse -except ImportError: - # For Django 1.8 - from django.urls import reverse - from django.db import models from django.templatetags.static import static +from django.urls import reverse from imagekit.cachefiles import ImageCacheFile -from ..core.models import DiffModelMixin, DittoItemModel, TimeStampedModelMixin +from ditto.core.models import DiffModelMixin, DittoItemModel, TimeStampedModelMixin + from . import app_settings, imagegenerators, managers from .utils import htmlify_description, htmlify_tweet @@ -61,37 +57,35 @@ class Account(TimeStampedModelMixin, models.Model): help_text="If false, new Tweets won't be fetched.", ) + class Meta: + ordering = ["user__screen_name"] + + def __str__(self): + if self.user: + return str(self.user) + else: + return "%d" % self.pk + def save(self, *args, **kwargs): if self.user is None: - result = self.updateUserFromTwitter() + result = self.update_user_from_twitter() # It would be nice to make this more visible, but not sure how to # given we don't have access to a request at this point. if ( - type(result) is dict + isinstance(result, dict) and "success" in result and result["success"] is False ): - if "messages" in result: - messages = ", ".join(result["messages"]) - else: - messages = "" + messages = ", ".join(result["messages"]) if "messages" in result else "" logger.error( "There was an error while trying to update the User data from " - f"the Twitter API: {messages}" + "the Twitter API: %s", + messages, ) super().save(*args, **kwargs) - def __str__(self): - if self.user: - return str(self.user) - else: - return "%d" % self.pk - - class Meta: - ordering = ["user__screen_name"] - def get_absolute_url(self): if self.user: return reverse( @@ -100,7 +94,7 @@ def get_absolute_url(self): else: return "" - def updateUserFromTwitter(self): + def update_user_from_twitter(self): """Calls the Twitter API to fetch the user details for this account. If the user object doesn't exist yet, it is created. But its relationship with this Account isn't saved here, only assigned. @@ -126,15 +120,25 @@ def updateUserFromTwitter(self): def has_credentials(self): "Does this at least have something in its API fields? True or False" - if ( + return ( self.consumer_key and self.consumer_secret and self.access_token and self.access_token_secret - ): - return True - else: - return False + ) + + +def media_upload_path(obj, filename): + "Make path under MEDIA_ROOT where original files will be saved." + + # eg get '12345678' from '12345678.jpg': + name = os.path.splitext(filename)[0] + + # If filename is '12345678.jpg': + # 'twitter/media/56/78/12345678.jpg' + return "/".join( + [app_settings.TWITTER_DIR_BASE, "media", name[-4:-2], name[-2:], filename] + ) class Media(TimeStampedModelMixin, models.Model): @@ -218,23 +222,11 @@ class Media(TimeStampedModelMixin, models.Model): ) # END VIDEO-ONLY PROPERTIES - def upload_path(self, filename): - "Make path under MEDIA_ROOT where original files will be saved." - - # eg get '12345678' from '12345678.jpg': - name = os.path.splitext(filename)[0] - - # If filename is '12345678.jpg': - # 'twitter/media/56/78/12345678.jpg' - return "/".join( - [app_settings.TWITTER_DIR_BASE, "media", name[-4:-2], name[-2:], filename] - ) - image_file = models.ImageField( - upload_to=upload_path, null=False, blank=True, default="" + upload_to=media_upload_path, null=False, blank=True, default="" ) mp4_file = models.FileField( - upload_to=upload_path, + upload_to=media_upload_path, null=False, blank=True, default="", @@ -247,21 +239,18 @@ def upload_path(self, filename): # There is no Public manager for media, as we don't have the concept of # private/public Media items. - def __str__(self): - return "%s %d" % (self.get_media_type_display(), self.id) - class Meta: ordering = ["time_created"] verbose_name = "Media item" verbose_name_plural = "Media items" + def __str__(self): + return "%s %d" % (self.get_media_type_display(), self.id) + @property def has_file(self): "Do we have a file saved at all?" - if self.image_file.name or self.mp4_file.name: - return True - else: - return False + return self.image_file.name or self.mp4_file.name @property def thumbnail_w(self): @@ -368,7 +357,7 @@ def _local_image_url(self, size): image_generator = generator(source=self.image_file) result = ImageCacheFile(image_generator) return result.url - except Exception: + except Exception: # noqa: BLE001 # We have an original file but something's wrong with it. # Might be 0 bytes or something. return static("ditto-core/img/original_error.jpg") @@ -381,7 +370,7 @@ def _remote_image_url(self, size): Generate the URL of an image of a particular size, at Twitter. size -- one of 'large', 'medium', 'small', or 'thumbnail'. """ - return "%s:%s" % (self.image_url, size) + return f"{self.image_url}:{size}" def _video_type(self): """ @@ -585,7 +574,7 @@ def get_next_public_by_post_time(self): .order_by("post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass def get_previous_public_by_post_time(self): @@ -599,7 +588,7 @@ def get_previous_public_by_post_time(self): .order_by("-post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass # Shortcuts: @@ -611,16 +600,13 @@ def get_previous(self): @property def is_reply(self): - if self.in_reply_to_screen_name == "": - return False - else: - return True + return self.in_reply_to_screen_name == "" @property def in_reply_to_url(self): "If it's a reply, the link to the tweet replied to." if self.is_reply: - return "https://twitter.com/%s/status/%s" % ( + return "https://twitter.com/{}/status/{}".format( self.in_reply_to_screen_name, self.in_reply_to_status_id, ) @@ -666,10 +652,8 @@ def get_quoted_tweet(self): tweet = None if self.quoted_status_id: - try: + with contextlib.suppress(Tweet.DoesNotExist): tweet = Tweet.public_objects.get(twitter_id=self.quoted_status_id) - except Tweet.DoesNotExist: - pass # Save for later: self._quoted_tweet = tweet @@ -682,10 +666,8 @@ def get_retweeted_tweet(self): tweet = None if self.retweeted_status_id: - try: + with contextlib.suppress(Tweet.DoesNotExist): tweet = Tweet.public_objects.get(twitter_id=self.retweeted_status_id) - except Tweet.DoesNotExist: - pass # Save for later: self._retweeted_tweet = tweet @@ -696,6 +678,35 @@ def _summary_source(self): return self.title +def avatar_upload_path(obj, filename): + """ + Make path under MEDIA_ROOT where avatar file will be saved. + + eg, if twitter_id is 12345678: + twitter/avatars/56/78/12345678/avatar_name.jpg + """ + + # We can't just join all these parts in one go, because if the ID + # isn't long enough to have two numbered directories, (ie, it's only + # 1 or 2 digits) then Django 1.8 creates a path with '//' rather than + # just ignoring that directory. + + # So this is a bit laborious: + + # 'twitter/avatars': + start = "/".join([app_settings.TWITTER_DIR_BASE, "avatars"]) + # '/78/12345678/avatar_name.jpg': + end = "/".join([str(obj.twitter_id)[-2:], str(obj.twitter_id), str(filename)]) + # The bit that will be empty for 1-2 digit IDs: + # '56': + middle = str(obj.twitter_id)[-4:-2] + + if middle: + return "/".join([start, middle, end]) + else: + return "/".join([start, end]) + + class User(TimeStampedModelMixin, DiffModelMixin, models.Model): """A Twitter user. We don't replicate all of the possible User attributes here, only enough @@ -782,34 +793,6 @@ class User(TimeStampedModelMixin, DiffModelMixin, models.Model): null=False, blank=True, help_text="eg, the raw JSON from the API." ) - def avatar_upload_path(self, filename): - """ - Make path under MEDIA_ROOT where avatar file will be saved. - - eg, if twitter_id is 12345678: - twitter/avatars/56/78/12345678/avatar_name.jpg - """ - - # We can't just join all these parts in one go, because if the ID - # isn't long enough to have two numbered directories, (ie, it's only - # 1 or 2 digits) then Django 1.8 creates a path with '//' rather than - # just ignoring that directory. - - # So this is a bit laborious: - - # 'twitter/avatars': - start = "/".join([app_settings.TWITTER_DIR_BASE, "avatars"]) - # '/78/12345678/avatar_name.jpg': - end = "/".join([str(self.twitter_id)[-2:], str(self.twitter_id), str(filename)]) - # The bit that will be empty for 1-2 digit IDs: - # '56': - middle = str(self.twitter_id)[-4:-2] - - if middle: - return "/".join([start, middle, end]) - else: - return "/".join([start, end]) - avatar = models.ImageField( upload_to=avatar_upload_path, null=False, blank=True, default="" ) @@ -820,12 +803,12 @@ def avatar_upload_path(self, filename): # All Users that have Accounts: objects_with_accounts = managers.WithAccountsManager() - def __str__(self): - return "@%s" % self.screen_name - class Meta: ordering = ["screen_name"] + def __str__(self): + return "@%s" % self.screen_name + def save(self, *args, **kwargs): """If the user's privacy status has changed, we need to change the privacy of all their tweets diff --git a/ditto/twitter/templatetags/ditto_twitter.py b/ditto/twitter/templatetags/ditto_twitter.py index 6f595ff..7cc9df3 100644 --- a/ditto/twitter/templatetags/ditto_twitter.py +++ b/ditto/twitter/templatetags/ditto_twitter.py @@ -1,9 +1,9 @@ -from datetime import datetime, time, timezone +from datetime import UTC, datetime, time from django import template -from ...core.utils import get_annual_item_counts -from ..models import Tweet, User +from ditto.core.utils import get_annual_item_counts +from ditto.twitter.models import Tweet, User register = template.Library() @@ -57,8 +57,8 @@ def day_tweets(date, screen_name=None): screen_name -- A Twitter user's screen_name. If not supplied, we fetch all public Tweets. """ - start = datetime.combine(date, time.min).replace(tzinfo=timezone.utc) - end = datetime.combine(date, time.max).replace(tzinfo=timezone.utc) + start = datetime.combine(date, time.min).replace(tzinfo=UTC) + end = datetime.combine(date, time.max).replace(tzinfo=UTC) tweets = Tweet.public_tweet_objects.filter(post_time__range=[start, end]) if screen_name is not None: tweets = tweets.filter(user__screen_name=screen_name) @@ -81,8 +81,8 @@ def day_favorites(date, screen_name=None): screen_name -- A Twitter user's screen_name. If not supplied, we fetch all public Tweets. """ - start = datetime.combine(date, time.min).replace(tzinfo=timezone.utc) - end = datetime.combine(date, time.max).replace(tzinfo=timezone.utc) + start = datetime.combine(date, time.min).replace(tzinfo=UTC) + end = datetime.combine(date, time.max).replace(tzinfo=UTC) if screen_name is None: tweets = Tweet.public_favorite_objects.filter(post_time__range=[start, end]) else: diff --git a/ditto/twitter/utils.py b/ditto/twitter/utils.py index 83ca735..32533ac 100644 --- a/ditto/twitter/utils.py +++ b/ditto/twitter/utils.py @@ -1,4 +1,3 @@ -# coding: utf-8 import re from django.utils.html import urlize @@ -128,8 +127,9 @@ def htmlify_tweet(json_data): # the page. All being well. for item in ents["media"]: html = html.replace( - '%s' - % (item["url"], item["display_url"]), + '{}'.format( + item["url"], item["display_url"] + ), "", ) diff --git a/ditto/twitter/views.py b/ditto/twitter/views.py index 91eabc2..3bf9841 100644 --- a/ditto/twitter/views.py +++ b/ditto/twitter/views.py @@ -1,7 +1,8 @@ from django.views.generic import DetailView from django.views.generic.detail import SingleObjectMixin -from ..core.views import PaginatedListView +from ditto.core.views import PaginatedListView + from .models import Account, Tweet, User @@ -74,6 +75,7 @@ def get_context_data(self, **kwargs): class AccountFavoriteListView(SingleUserMixin, PaginatedListView): "A single Twitter User associated with an Account, and its Favorites." + template_name = "twitter/account_favorite_list.html" def get_queryset(self): diff --git a/docs/conf.py b/docs/conf.py index d523280..c810821 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- # # Django Ditto documentation build configuration file, created by # sphinx-quickstart on Mon Jun 20 17:02:21 2016. @@ -68,9 +67,9 @@ def get_entity(package, entity): `__init__.py`. """ path = os.path.join(os.path.dirname(__file__), "..", "ditto", "__init__.py") - init_py = open(path).read() - find = "__%s__ = ['\"]([^'\"]+)['\"]" % entity - return re.search(find, init_py).group(1) + with open(path).read() as init_py: + find = "__%s__ = ['\"]([^'\"]+)['\"]" % entity + return re.search(find, init_py).group(1) def get_version(): diff --git a/setup.py b/setup.py index ff8ed2c..52a3df1 100644 --- a/setup.py +++ b/setup.py @@ -21,9 +21,9 @@ def get_entity(package, entity): eg, get_entity('ditto', 'version') returns `__version__` value in `__init__.py`. """ - init_py = open(os.path.join(package, "__init__.py")).read() - find = "__%s__ = ['\"]([^'\"]+)['\"]" % entity - return re.search(find, init_py).group(1) + with open(os.path.join(package, "__init__.py")).read() as init_py: + find = "__%s__ = ['\"]([^'\"]+)['\"]" % entity + return re.search(find, init_py).group(1) def get_version(): diff --git a/tests/core/__init__.py b/tests/core/__init__.py index 3803206..3a51d98 100644 --- a/tests/core/__init__.py +++ b/tests/core/__init__.py @@ -32,7 +32,7 @@ def __override_app_settings(*args, **kwargs): result = func(*args, **kwargs) - for key, value in test_settings.items(): + for key, _value in test_settings.items(): setattr(app_settings, key, old_values[key]) return result diff --git a/tests/core/test_apps.py b/tests/core/test_apps.py index 0c97bfa..d26a6f2 100644 --- a/tests/core/test_apps.py +++ b/tests/core/test_apps.py @@ -1,4 +1,3 @@ -# coding: utf-8 from unittest.mock import patch from django.test import TestCase diff --git a/tests/core/test_paginator.py b/tests/core/test_paginator.py index 3e1fa2c..5792366 100644 --- a/tests/core/test_paginator.py +++ b/tests/core/test_paginator.py @@ -1,4 +1,3 @@ -# coding: utf-8 from django.core import paginator as django_paginator from django.test import TestCase diff --git a/tests/core/test_templatetags.py b/tests/core/test_templatetags.py index f2d67da..d74ff47 100644 --- a/tests/core/test_templatetags.py +++ b/tests/core/test_templatetags.py @@ -99,7 +99,7 @@ def test_returns_time_with_no_link(self): def test_returns_time_with_link(self, reverse): reverse.return_value = "/2015/08/14/" self.assertEqual( - display_time(datetime_now(), True), + display_time(datetime_now(), link_to_day=True), ( '' @@ -171,7 +171,6 @@ def test_case_capfirst(self): @override_app_settings(CORE_DATE_FORMAT="%B %d, %Y") @freeze_time("2015-08-14 13:34:56") def test_returns_time_with_no_link_custom_date_and_time(self): - self.assertEqual( display_time(datetime_now()), '', @@ -180,7 +179,6 @@ def test_returns_time_with_no_link_custom_date_and_time(self): @override_app_settings(CORE_DATETIME_FORMAT="[time] on the day [date]") @freeze_time("2015-08-14 13:34:56") def test_returns_time_with_no_link_custom_date_time(self): - self.assertEqual( display_time(datetime_now()), '', diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index ff00e02..b185201 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,5 +1,4 @@ -# coding: utf-8 -from datetime import datetime, timezone +from datetime import UTC, datetime import responses from django.test import TestCase @@ -13,7 +12,7 @@ class DatetimeNowTestCase(TestCase): @freeze_time("2015-08-14 12:00:00", tz_offset=-8) def test_datetime_now(self): - self.assertEqual(datetime_now(), datetime.utcnow().replace(tzinfo=timezone.utc)) + self.assertEqual(datetime_now(), datetime.now(tz=UTC)) class DatetimeFromStrTestCase(TestCase): @@ -21,7 +20,7 @@ def test_datetime_from_str(self): s = "2015-08-12 12:00:00" self.assertEqual( datetime_from_str(s), - datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc), + datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC), ) @@ -30,10 +29,8 @@ def test_truncate_string_strip_html(self): "By default, strips HTML" self.assertEqual( truncate_string( - ( - '

Some text. A link' - ". And more." - ) + '

Some text. A link' + ". And more." ), "Some text. A link. And more.", ) @@ -170,7 +167,6 @@ def do_download(self, status=200, content_type="image/jpeg"): "Mocks requests and calls filedownloader.download()" # Open the image we're going to pretend we're fetching from the URL: with open("tests/core/fixtures/images/marmite.jpg", "rb") as img1: - responses.add( responses.GET, self.url, @@ -227,7 +223,7 @@ def test_make_filename_from_url(self): def test_make_filename_from_content_disposition(self): "If URL has no filename, should use the Content-Disposition filename." filename = filedownloader.make_filename( - "https://www.flickr.com/photos/philgyford/26348530105/play/orig/2b5f3e0919/", # noqa: E501 + "https://www.flickr.com/photos/philgyford/26348530105/play/orig/2b5f3e0919/", {"Content-Disposition": "attachment; filename=26348530105.mov"}, ) self.assertEqual(filename, "26348530105.mov") diff --git a/tests/flickr/test_fetch.py b/tests/flickr/test_fetch.py index e280ec8..fb85cea 100644 --- a/tests/flickr/test_fetch.py +++ b/tests/flickr/test_fetch.py @@ -1,12 +1,10 @@ import json -from datetime import datetime, timezone -from urllib.parse import quote_plus +from datetime import UTC, datetime +from urllib.parse import parse_qs, quote_plus from zoneinfo import ZoneInfo import responses -import six from django.test import TestCase -from six.moves.urllib.parse import parse_qs from ditto.flickr.fetch.savers import SaveUtilsMixin @@ -35,7 +33,7 @@ def test_api_datetime_to_datetime_custom_tz(self): def test_unixtime_to_datetime(self): api_time = "1093459273" time1 = SaveUtilsMixin()._unixtime_to_datetime(api_time) - time2 = datetime.utcfromtimestamp(int(api_time)).replace(tzinfo=timezone.utc) + time2 = datetime.fromtimestamp(int(api_time), tz=UTC) self.assertEqual(time1, time2) @@ -58,23 +56,22 @@ class FlickrFetchTestCase(TestCase): } def setUp(self): - super(FlickrFetchTestCase, self).setUp() + super().setUp() self.mock = responses.RequestsMock(assert_all_requests_are_fired=True) self.mock.start() def tearDown(self): self.mock.stop() self.mock.reset() - super(FlickrFetchTestCase, self).tearDown() + super().tearDown() def load_raw_fixture(self, method): """Makes the JSON response to a call to the API. method -- Method name used in self.flickr_fixtures. Returns the JSON text. """ - json_file = open("%s%s" % (self.fixture_path, self.flickr_fixtures[method])) - json_data = json_file.read() - json_file.close() + with open(f"{self.fixture_path}{self.flickr_fixtures[method]}") as f: + json_data = f.read() return json_data def load_fixture(self, method): @@ -84,6 +81,7 @@ def load_fixture(self, method): def expect( self, + *, params=None, body="", status=200, @@ -92,7 +90,7 @@ def expect( match_querystring=True, ): """Mocks an expected HTTP query with Responses. - Mostly copied from https://github.com/sybrenstuvel/flickrapi/blob/master/tests/test_flickrapi.py # noqa: E501 + Mostly copied from https://github.com/sybrenstuvel/flickrapi/blob/master/tests/test_flickrapi.py """ urlbase = "https://api.flickr.com/services/rest/" @@ -108,11 +106,11 @@ def expect( # The parameters should be on the URL. qp = quote_plus qs = "&".join( - "%s=%s" % (qp(key), qp(six.text_type(value).encode("utf-8"))) + "{}={}".format(qp(key), qp(str(value).encode("utf-8"))) for key, value in sorted(params.items()) ) if qs: - url = "%s?%s" % (urlbase, qs) + url = f"{urlbase}?{qs}" self.mock.add( method=method, @@ -197,6 +195,6 @@ def expect_response(self, method, body=None, params=None): params.setdefault(k, v) # Add the param specifying the API method: - params.setdefault("method", "flickr.{}".format(method)) + params.setdefault("method", f"flickr.{method}") self.expect(params=params, body=body) diff --git a/tests/flickr/test_fetch_fetchers.py b/tests/flickr/test_fetch_fetchers.py index c58e96f..8bc4e0c 100644 --- a/tests/flickr/test_fetch_fetchers.py +++ b/tests/flickr/test_fetch_fetchers.py @@ -1,7 +1,7 @@ import json import os import tempfile -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import call, patch from django.test import override_settings @@ -103,7 +103,7 @@ def test_failure_with_no_child_save_results(self, call_api): class UserIdFetcherTestCase(FlickrFetchTestCase): def setUp(self): - super(UserIdFetcherTestCase, self).setUp() + super().setUp() self.account = AccountFactory(api_key="1234", api_secret="9876") def test_inherits_from_fetcher(self): @@ -128,7 +128,7 @@ def test_returns_id(self): class UserFetcherTestCase(FlickrFetchTestCase): def setUp(self): - super(UserFetcherTestCase, self).setUp() + super().setUp() self.account = AccountFactory(api_key="1234", api_secret="9876") def test_inherits_from_fetcher(self): @@ -214,7 +214,7 @@ def test_deleted_user(self): self.assertEqual(user.photos_count, 0) dt = datetime.strptime("1970-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ) self.assertEqual(user.photos_first_date, dt) self.assertEqual(user.photos_first_date_taken, dt) @@ -225,7 +225,7 @@ class PhotosFetcherTestCase(FlickrFetchTestCase): photos.""" def setUp(self): - super(PhotosFetcherTestCase, self).setUp() + super().setUp() account = AccountFactory(api_key="1234", api_secret="9876") self.fetcher = PhotosFetcher(account=account) @@ -367,7 +367,7 @@ def test_fetch_photo_exif_throws_exception(self): class RecentPhotosFetcherTestCase(FlickrFetchTestCase): def setUp(self): - super(RecentPhotosFetcherTestCase, self).setUp() + super().setUp() account = AccountFactory( api_key="1234", api_secret="9876", user=UserFactory(nsid="35034346050@N01") ) @@ -456,7 +456,7 @@ def test_saves_photos( class PhotosetsFetcherTestCase(FlickrFetchTestCase): def setUp(self): - super(PhotosetsFetcherTestCase, self).setUp() + super().setUp() account = AccountFactory( api_key="1234", api_secret="9876", user=UserFactory(nsid="35034346050@N01") ) diff --git a/tests/flickr/test_fetch_filesfetchers.py b/tests/flickr/test_fetch_filesfetchers.py index 850cd34..96c5f29 100644 --- a/tests/flickr/test_fetch_filesfetchers.py +++ b/tests/flickr/test_fetch_filesfetchers.py @@ -1,6 +1,6 @@ import os import tempfile -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import call, patch from django.test import TestCase, override_settings @@ -19,9 +19,7 @@ def setUp(self): self.photo_1 = PhotoFactory(title="p1", original_file="p1.jpg", user=user) - the_time = datetime.strptime("2015-08-14", "%Y-%m-%d").replace( - tzinfo=timezone.utc - ) + the_time = datetime.strptime("2015-08-14", "%Y-%m-%d").replace(tzinfo=UTC) # Needs a taken_time for testing file save path: # post_time will put them in order. @@ -158,8 +156,7 @@ def test_saves_downloaded_photo_file(self, download): nsid = nsid[: nsid.index("@")] self.assertEqual( self.photo_2.original_file.name, - "flickr/%s/%s/%s/photos/2015/08/14/%s" - % ( + "flickr/{}/{}/{}/photos/2015/08/14/{}".format( nsid[-4:-2], nsid[-2:], self.photo_2.user.nsid.replace("@", ""), @@ -180,8 +177,7 @@ def test_saves_downloaded_video_file(self, download): nsid = nsid[: nsid.index("@")] self.assertEqual( self.video_2.video_original_file.name, - "flickr/%s/%s/%s/photos/2015/08/14/%s" - % ( + "flickr/{}/{}/{}/photos/2015/08/14/{}".format( nsid[-4:-2], nsid[-2:], self.video_2.user.nsid.replace("@", ""), diff --git a/tests/flickr/test_fetch_savers.py b/tests/flickr/test_fetch_savers.py index cc9f27f..47fb639 100644 --- a/tests/flickr/test_fetch_savers.py +++ b/tests/flickr/test_fetch_savers.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from unittest.mock import patch from zoneinfo import ZoneInfo @@ -22,7 +22,7 @@ def make_user_object(self, user_data): """ "Creates/updates a User from API data, then fetches that User from the DB and returns it. """ - fetch_time = datetime.utcnow().replace(tzinfo=timezone.utc) + fetch_time = datetime.now(tz=UTC) UserSaver().save_user(user_data, fetch_time) return User.objects.get(nsid="35034346050@N01") @@ -33,9 +33,7 @@ def test_saves_correct_user_data(self): user_data = self.load_fixture("people.getInfo") user = self.make_user_object(user_data["person"]) - self.assertEqual( - user.fetch_time, datetime.utcnow().replace(tzinfo=timezone.utc) - ) + self.assertEqual(user.fetch_time, datetime.now(tz=UTC)) self.assertEqual(user.raw, json.dumps(user_data["person"])) self.assertEqual(user.nsid, "35034346050@N01") self.assertTrue(user.is_pro) @@ -50,12 +48,12 @@ def test_saves_correct_user_data(self): self.assertEqual(user.photos_count, 2876) self.assertEqual( user.photos_first_date, - datetime.utcfromtimestamp(1093459273).replace(tzinfo=timezone.utc), + datetime.fromtimestamp(1093459273, tz=UTC), ) self.assertEqual( user.photos_first_date_taken, datetime.strptime("1956-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual(user.photos_views, 227227) @@ -99,7 +97,7 @@ def make_photo_object(self, photo_data): def make_photo_data(self): """Makes the dict of data that photo_save() expects, based on API data.""" return { - "fetch_time": datetime.utcnow().replace(tzinfo=timezone.utc), + "fetch_time": datetime.now(tz=UTC), "user_obj": UserFactory(nsid="35034346050@N01"), "info": self.load_fixture("photos.getInfo")["photo"], "exif": self.load_fixture("photos.getExif")["photo"], @@ -121,13 +119,11 @@ def test_saves_correct_photo_data(self, save_tags): ) self.assertFalse(photo.is_private) self.assertEqual(photo.summary, "Some test HTML. And another paragraph.") - self.assertEqual( - photo.fetch_time, datetime.utcnow().replace(tzinfo=timezone.utc) - ) + self.assertEqual(photo.fetch_time, datetime.now(tz=UTC)) self.assertEqual( photo.post_time, datetime.strptime("2016-03-28 16:05:05", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual(photo.latitude, Decimal("51.967930000")) @@ -152,7 +148,7 @@ def test_saves_correct_photo_data(self, save_tags): self.assertEqual( photo.last_update_time, datetime.strptime("2016-04-04 13:21:11", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual( @@ -379,7 +375,7 @@ def make_photoset_object(self, photoset_data): def make_photoset_data(self): """Makes the dict of data that photo_save() expects, based on API data.""" return { - "fetch_time": datetime.utcnow().replace(tzinfo=timezone.utc), + "fetch_time": datetime.now(tz=UTC), "user_obj": UserFactory(nsid="35034346050@N01"), "photoset": self.load_fixture("photosets.getList")["photosets"]["photoset"][ 0 @@ -392,9 +388,7 @@ def test_saves_correct_photoset_data(self): photoset_data = self.make_photoset_data() photoset = self.make_photoset_object(photoset_data) - self.assertEqual( - photoset.fetch_time, datetime.utcnow().replace(tzinfo=timezone.utc) - ) + self.assertEqual(photoset.fetch_time, datetime.now(tz=UTC)) self.assertEqual(photoset.user, photoset_data["user_obj"]) self.assertEqual(photoset.flickr_id, 72157665648859705) @@ -409,13 +403,13 @@ def test_saves_correct_photoset_data(self): self.assertEqual( photoset.last_update_time, datetime.strptime("2016-03-28 16:02:03", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual( photoset.flickr_created_time, datetime.strptime("2016-03-08 19:37:04", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual(photoset.raw, json.dumps(photoset_data["photoset"])) diff --git a/tests/flickr/test_management_commands.py b/tests/flickr/test_management_commands.py index 9ad2464..5740ad8 100644 --- a/tests/flickr/test_management_commands.py +++ b/tests/flickr/test_management_commands.py @@ -93,7 +93,7 @@ def setUp(self): self.out_err = StringIO() @patch( - "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" ) def test_sends_all_true_to_fetcher_with_account(self, fetcher): call_command("fetch_flickr_originals", "--all", account="99999999999@N99") @@ -101,7 +101,7 @@ def test_sends_all_true_to_fetcher_with_account(self, fetcher): fetcher.return_value.fetch.assert_called_with(fetch_all=True) @patch( - "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" ) def test_sends_all_true_to_fetcher_no_account(self, fetcher): call_command("fetch_flickr_originals", "--all") @@ -109,7 +109,7 @@ def test_sends_all_true_to_fetcher_no_account(self, fetcher): fetcher.return_value.fetch.assert_called_with(fetch_all=True) @patch( - "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" ) def test_sends_all_false_to_fetcher(self, fetcher): call_command("fetch_flickr_originals") @@ -117,7 +117,7 @@ def test_sends_all_false_to_fetcher(self, fetcher): fetcher.return_value.fetch.assert_called_with(fetch_all=False) @patch( - "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" ) def test_success_output(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -127,7 +127,7 @@ def test_success_output(self, fetcher): self.assertIn("Phil Gyford: Fetched 33 Files", self.out.getvalue()) @patch( - "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" ) def test_success_output_verbosity_0(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -137,7 +137,7 @@ def test_success_output_verbosity_0(self, fetcher): self.assertEqual("", self.out.getvalue()) @patch( - "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_originals.OriginalFilesMultiAccountFetcher" ) def test_error_output(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -179,7 +179,7 @@ def test_fail_with_days_and_end(self): # Sending --days argument @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_days_to_fetcher_with_account(self, fetcher): call_command("fetch_flickr_photos", account="99999999999@N99", days="4") @@ -187,7 +187,7 @@ def test_sends_days_to_fetcher_with_account(self, fetcher): fetcher.return_value.fetch.assert_called_with(days=4, start=None, end=None) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_days_to_fetcher_no_account(self, fetcher): call_command("fetch_flickr_photos", days="4") @@ -195,7 +195,7 @@ def test_sends_days_to_fetcher_no_account(self, fetcher): fetcher.return_value.fetch.assert_called_with(days=4, start=None, end=None) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_all_to_fetcher_with_account(self, fetcher): call_command("fetch_flickr_photos", account="99999999999@N99", days="all") @@ -205,7 +205,7 @@ def test_sends_all_to_fetcher_with_account(self, fetcher): # Sending --start argument @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_start_to_fetcher_with_account(self, fetcher): call_command( @@ -217,7 +217,7 @@ def test_sends_start_to_fetcher_with_account(self, fetcher): ) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_start_to_fetcher_with_no_account(self, fetcher): call_command("fetch_flickr_photos", start="2022-01-31") @@ -229,7 +229,7 @@ def test_sends_start_to_fetcher_with_no_account(self, fetcher): # Sending --end argument @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_end_to_fetcher_with_account(self, fetcher): call_command("fetch_flickr_photos", account="99999999999@N99", end="2022-01-31") @@ -239,7 +239,7 @@ def test_sends_end_to_fetcher_with_account(self, fetcher): ) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_end_to_fetcher_with_no_account(self, fetcher): call_command("fetch_flickr_photos", end="2022-01-31") @@ -251,7 +251,7 @@ def test_sends_end_to_fetcher_with_no_account(self, fetcher): # Sending --start and --end arguments @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_start_and_end_to_fetcher_with_account(self, fetcher): call_command( @@ -266,7 +266,7 @@ def test_sends_start_and_end_to_fetcher_with_account(self, fetcher): ) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_sends_start_and_end_to_fetcher_with_no_account(self, fetcher): call_command("fetch_flickr_photos", start="2022-01-31", end="2022-02-14") @@ -278,7 +278,7 @@ def test_sends_start_and_end_to_fetcher_with_no_account(self, fetcher): # Outputs @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_success_output(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -288,7 +288,7 @@ def test_success_output(self, fetcher): self.assertIn("Phil Gyford: Fetched 40 Photos", self.out.getvalue()) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_success_output_verbosity_0(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -298,7 +298,7 @@ def test_success_output_verbosity_0(self, fetcher): self.assertEqual("", self.out.getvalue()) @patch( - "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photos.RecentPhotosMultiAccountFetcher" ) def test_error_output(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -318,7 +318,7 @@ def setUp(self): self.out_err = StringIO() @patch( - "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" ) def test_calls_fetcher_with_account(self, fetcher): call_command("fetch_flickr_photosets", account="99999999999@N99") @@ -326,7 +326,7 @@ def test_calls_fetcher_with_account(self, fetcher): fetcher.return_value.fetch.assert_called_with() @patch( - "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" ) def test_calls_fetcher_with_no_account(self, fetcher): call_command("fetch_flickr_photosets") @@ -334,7 +334,7 @@ def test_calls_fetcher_with_no_account(self, fetcher): fetcher.return_value.fetch.assert_called_with() @patch( - "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" ) def test_success_output(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -344,7 +344,7 @@ def test_success_output(self, fetcher): self.assertIn("Phil Gyford: Fetched 40 Photosets", self.out.getvalue()) @patch( - "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" ) def test_success_output_verbosity_0(self, fetcher): fetcher.return_value.fetch.return_value = [ @@ -354,7 +354,7 @@ def test_success_output_verbosity_0(self, fetcher): self.assertEqual("", self.out.getvalue()) @patch( - "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" # noqa: E501 + "ditto.flickr.management.commands.fetch_flickr_photosets.PhotosetsMultiAccountFetcher" ) def test_error_output(self, fetcher): fetcher.return_value.fetch.return_value = [ diff --git a/tests/flickr/test_models.py b/tests/flickr/test_models.py index d30dad8..bca1d7c 100644 --- a/tests/flickr/test_models.py +++ b/tests/flickr/test_models.py @@ -1,5 +1,5 @@ import os -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import Mock, patch from django.db import IntegrityError @@ -136,13 +136,13 @@ def test_photoset_ordering(self): title="Earliest", flickr_created_time=datetime.strptime( "2016-04-07 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) PhotosetFactory( title="Latest", flickr_created_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) photosets = Photoset.objects.all() self.assertEqual(photosets[0].title, "Latest") @@ -214,13 +214,13 @@ def test_ordering(self): title="Earliest", post_time=datetime.strptime( "2016-04-07 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) PhotoFactory( title="Latest", post_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) photos = Photo.objects.all() self.assertEqual(photos[0].title, "Latest") @@ -439,7 +439,7 @@ def test_image_urls(self): def test_image_url_with_invalid_size(self): with self.assertRaises(AttributeError): - self.photo.an_invalid_url + _url = self.photo.an_invalid_url def test_video_urls_video(self): "Videos should return the correct URLs from the video url properties." @@ -448,20 +448,15 @@ def test_video_urls_video(self): media="video", permalink=permalink, secret=9876, original_secret="7777" ) for size, prop in self.video_sizes.items(): - if size == "orig": - secret = 7777 - else: - secret = 9876 + secret = 7777 if size == "orig" else 9876 - self.assertEqual( - getattr(photo, prop), "%splay/%s/%s/" % (permalink, size, secret) - ) + self.assertEqual(getattr(photo, prop), f"{permalink}play/{size}/{secret}/") def test_video_urls_photo(self): "Photos should have None for all video URLs." photo = PhotoFactory(media="photo") - for size, prop in self.video_sizes.items(): + for _size, prop in self.video_sizes.items(): self.assertIsNone(getattr(photo, prop)) @@ -478,7 +473,7 @@ def setUp(self): user=UserFactory(nsid="123456@N01"), taken_time=datetime.strptime( "2015-08-14 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) def tearDown(self): @@ -510,20 +505,20 @@ def test_medium_url(self): "Has a different format to most other image sizes." self.assertRegex( self.photo.medium_url, - r"CACHE/images/flickr/34/56/123456N01/photos/2015/08/14/example.[^\.]+\.jpg", # noqa: E501 + r"CACHE/images/flickr/34/56/123456N01/photos/2015/08/14/example.[^\.]+\.jpg", ) def test_image_urls(self): """Test all but the Original and Medium image URL properties.""" - for size, prop in self.photo_sizes.items(): + for _size, prop in self.photo_sizes.items(): self.assertRegex( getattr(self.photo, prop), - r"CACHE/images/flickr/34/56/123456N01/photos/2015/08/14/example.[^\.]+\.jpg", # noqa: E501 + r"CACHE/images/flickr/34/56/123456N01/photos/2015/08/14/example.[^\.]+\.jpg", ) def test_image_url_with_invalid_size(self): with self.assertRaises(AttributeError): - self.photo.an_invalid_url + _url = self.photo.an_invalid_url def test_video_urls_video(self): "Should currently return the remote URL for videos." @@ -532,14 +527,8 @@ def test_video_urls_video(self): media="video", permalink=permalink, secret=9876, original_secret="7777" ) for size, prop in self.video_sizes.items(): - if size == "orig": - secret = 7777 - else: - secret = 9876 - - self.assertEqual( - getattr(photo, prop), "%splay/%s/%s/" % (permalink, size, secret) - ) + secret = 7777 if size == "orig" else 9876 + self.assertEqual(getattr(photo, prop), f"{permalink}play/{size}/{secret}/") def test_image_url_when_original_missing(self): "If we have no original file, we should use the 'missing' image." @@ -567,14 +556,14 @@ def setUp(self): user=user, post_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) self.private_photo = PhotoFactory( user=user, is_private=True, post_time=datetime.strptime( "2016-04-09 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) # Photo by a different user: user_2 = UserFactory() @@ -583,13 +572,13 @@ def setUp(self): user=user_2, post_time=datetime.strptime( "2016-04-10 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) self.photo_2 = PhotoFactory( user=user, post_time=datetime.strptime( "2016-04-11 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc), + ).replace(tzinfo=UTC), ) def test_next_public_by_post_time(self): diff --git a/tests/flickr/test_templatetags.py b/tests/flickr/test_templatetags.py index d79129e..045e24d 100644 --- a/tests/flickr/test_templatetags.py +++ b/tests/flickr/test_templatetags.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from django.test import TestCase @@ -73,8 +73,8 @@ def setUp(self): self.photos_1 = PhotoFactory.create_batch(2, user=user_1) self.photos_2 = PhotoFactory.create_batch(3, user=user_2) - taken_time = datetime(2014, 3, 18, 12, 0, 0).replace(tzinfo=timezone.utc) - post_time = datetime(2015, 3, 18, 12, 0, 0).replace(tzinfo=timezone.utc) + taken_time = datetime(2014, 3, 18, 12, 0, 0, tzinfo=UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) self.photos_1[0].taken_time = taken_time self.photos_1[0].post_time = post_time self.photos_1[0].save() diff --git a/tests/flickr/test_views.py b/tests/flickr/test_views.py index 2234229..68b4f39 100644 --- a/tests/flickr/test_views.py +++ b/tests/flickr/test_views.py @@ -294,7 +294,7 @@ def setUp(self): TaggedPhotoFactory(content_object=self.cod_photo, tag=fish_tag) TaggedPhotoFactory(content_object=self.cod_photo, tag=cod_tag) - def createDogPhoto(self): + def create_dog_photo(self): "Creates a photo tagged with 'dog' and 'mammal'." self.dog_photo = PhotoFactory(title="Dog") mammal_tag = TagFactory(slug="mammal") @@ -344,7 +344,7 @@ def test_tag_detail_context(self): "Sends the correct data to the templates" AccountFactory.create_batch(2) # The 'fish' tag page shouldn't include this dog photo: - self.createDogPhoto() + self.create_dog_photo() response = self.client.get( reverse("flickr:tag_detail", kwargs={"slug": "fish"}) ) @@ -422,7 +422,7 @@ def test_user_tag_detail_templates(self): def test_user_tag_detail_context(self): "Sends the correct data to templates" - self.createDogPhoto() + self.create_dog_photo() # Ensure the cod, carp and dog photos are all owned by the same user. # Only the carp and cod pics should show up on the user's 'fish' page. @@ -523,7 +523,6 @@ def test_user_tag_detail_fails_2(self): class PhotosetViewTests(TestCase): def setUp(self): - self.user_1 = UserFactory(nsid="1234567890@N01") self.account_1 = AccountFactory(user=self.user_1) # Three photos, one of which is private. diff --git a/tests/lastfm/test_fetch.py b/tests/lastfm/test_fetch.py index 7b3d68f..9af7f41 100644 --- a/tests/lastfm/test_fetch.py +++ b/tests/lastfm/test_fetch.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import call, patch import responses @@ -131,9 +131,8 @@ def load_raw_fixture(self, fixture_name): fixture_name -- eg 'messages' to load 'messages.json'. Returns the JSON text. """ - json_file = open("%s%s.json" % (self.fixture_path, fixture_name)) - json_data = json_file.read() - json_file.close() + with open(f"{self.fixture_path}{fixture_name}.json") as f: + json_data = f.read() return json_data def load_fixture(self, fixture_name): @@ -215,7 +214,7 @@ def test_sends_from_time_correctly_for_recent(self): ScrobbleFactory( post_time=datetime.strptime( "2015-08-11 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc) + ).replace(tzinfo=UTC) ) # Timestamp for 2015-08-11 12:00:00 UTC: self.add_recent_tracks_response(from_time=1439294400) @@ -390,7 +389,7 @@ def test_updates_existing_scrobbles(self): track = TrackFactory(artist=artist, slug="make+up") post_time = datetime.strptime( "2016-09-22 09:23:33", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc) + ).replace(tzinfo=UTC) scrobble = ScrobbleFactory( account=self.account, track=track, post_time=post_time ) @@ -418,7 +417,7 @@ def test_sets_scrobble_data(self): self.assertEqual( scrobble.post_time, datetime.strptime("2016-09-22 09:23:33", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) json_data = self.load_fixture("user_getrecenttracks") diff --git a/tests/lastfm/test_utils.py b/tests/lastfm/test_utils.py index 6a5924a..df733ce 100644 --- a/tests/lastfm/test_utils.py +++ b/tests/lastfm/test_utils.py @@ -21,6 +21,6 @@ def test_changed_characters_1(self): def test_changed_characters_2(self): self.assertEqual( - slugify_name('" < > \ ^ ` { | }'), # noqa: W605 + slugify_name(r'" < > \ ^ ` { | }'), "%22+%3C+%3E+%5C%5C+%5E+%60+%7B+%7C+%7D", ) diff --git a/tests/lastfm/test_views.py b/tests/lastfm/test_views.py index 722b94c..2995c02 100644 --- a/tests/lastfm/test_views.py +++ b/tests/lastfm/test_views.py @@ -446,7 +446,7 @@ def test_7_days(self): self.assertEqual(response.context["track_list"][0].scrobble_count, 1) -class UserCommonTests(object): +class UserCommonTests: """Parent for all user-specific views. Doesn't inherit from TestCase because we don't want the tests in this class to run, only in its child classes. @@ -518,12 +518,10 @@ def test_404s(self): class UserDetailViewTestCase(UserCommonTests, TestCase): - view_name = "user_detail" class UserAlbumListViewTestCase(UserCommonTests, TestCase): - view_name = "user_album_list" def test_context_albums(self): @@ -539,7 +537,6 @@ def test_context_albums(self): class UserArtistListViewTestCase(UserCommonTests, TestCase): - view_name = "user_artist_list" def test_context_albums(self): @@ -557,7 +554,6 @@ def test_context_albums(self): class UserScrobbleListViewTestCase(UserCommonTests, TestCase): - view_name = "user_scrobble_list" def test_context_scrobbles(self): @@ -571,7 +567,6 @@ def test_context_scrobbles(self): class UserTrackListViewTestCase(UserCommonTests, TestCase): - view_name = "user_track_list" def test_context_tracks(self): diff --git a/tests/pinboard/test_fetch.py b/tests/pinboard/test_fetch.py index 05221da..1b4d7ab 100644 --- a/tests/pinboard/test_fetch.py +++ b/tests/pinboard/test_fetch.py @@ -1,6 +1,5 @@ -# coding: utf-8 import json -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import patch import responses @@ -64,10 +63,9 @@ def make_success_body( `method` is 'get' or 'recent' or 'all'. """ posts = [] - for n in range(0, num_posts): + for n in range(num_posts): posts.append( - '{"href":"http:\\/\\/example%s.com\\/","description":"My description %s","extended":"My extended %s.","meta":"abcdef1234567890abcdef1234567890","hash":"1234567890abcdef1234567890abcdef","time":"%sT09:48:31Z","shared":"yes","toread":"no","tags":"tag1 tag2 tag3"}' # noqa: E501 - % (n, n, n, post_date) + f'{"href":"http:\\/\\/example{n}.com\\/","description":"My description {n}","extended":"My extended {n}.","meta":"abcdef1234567890abcdef1234567890","hash":"1234567890abcdef1234567890abcdef","time":"{post_date}T09:48:31Z","shared":"yes","toread":"no","tags":"tag1 tag2 tag3"}' # noqa: E501 ) posts_json = "[%s]\t\n" % (",".join(posts)) @@ -75,11 +73,7 @@ def make_success_body( if method == "all": return posts_json else: - return '{"date":"%sT09:48:31Z","user":"%s","posts":%s}\t\n' % ( - post_date, - username, - posts_json, - ) + return f'{"date":"{post_date}T09:48:31Z","user":"{username}","posts":{posts_json}}\t\n' # noqa: E501 # Check that all interface methods return expected results on success. @@ -209,11 +203,11 @@ def test_no_bom(self): It didn't until June 2020 when Pinboard upgraded the server and something happened to add a BOM to API responses. """ - json_file = open("tests/pinboard/fixtures/api/bookmarks_no_bom.json") - self.add_response(body=json_file.read()) - result = DateBookmarksFetcher().fetch( - post_date="2015-06-18", username="philgyford" - ) + with open("tests/pinboard/fixtures/api/bookmarks_no_bom.json") as f: + self.add_response(body=f.read()) + result = DateBookmarksFetcher().fetch( + post_date="2015-06-18", username="philgyford" + ) self.assertEqual(result[0]["account"], "philgyford") self.assertTrue(result[0]["success"]) self.assertEqual(result[0]["fetched"], 1) @@ -224,11 +218,11 @@ def test_has_bom(self): It didn't until June 2020 when Pinboard upgraded the server and something happened to add a BOM to API responses. """ - json_file = open("tests/pinboard/fixtures/api/bookmarks_bom.json") - self.add_response(body=json_file.read()) - result = DateBookmarksFetcher().fetch( - post_date="2015-06-18", username="philgyford" - ) + with open("tests/pinboard/fixtures/api/bookmarks_bom.json") as f: + self.add_response(body=f.read()) + result = DateBookmarksFetcher().fetch( + post_date="2015-06-18", username="philgyford" + ) self.assertEqual(result[0]["account"], "philgyford") self.assertTrue(result[0]["success"]) self.assertEqual(result[0]["fetched"], 1) @@ -262,10 +256,9 @@ def get_bookmarks_from_json(self): what should be a list of correctly-parsed data about Bookmarks, ready to make Bookmark objects out of. """ - json_file = open(self.api_fixture) - json_data = json_file.read() - bookmarks_data = BookmarksFetcher()._parse_response("date", json_data) - json_file.close() + with open(self.api_fixture) as f: + json_data = f.read() + bookmarks_data = BookmarksFetcher()._parse_response("date", json_data) return {"json": json_data, "bookmarks": bookmarks_data} @@ -282,7 +275,7 @@ def test_fetch_json_parsing(self): self.assertEqual( bookmarks_data[0]["time"], datetime.strptime("2015-06-18T09:48:31Z", "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) @@ -310,7 +303,7 @@ def test_save_bookmarks(self): Bookmark objects. """ account = Account.objects.get(pk=1) - fetch_time = datetime.utcnow().replace(tzinfo=timezone.utc) + fetch_time = datetime.now(tz=UTC) bookmarks_from_json = self.get_bookmarks_from_json() bookmarks_data = bookmarks_from_json["bookmarks"] @@ -332,7 +325,7 @@ def test_save_bookmarks(self): self.assertEqual( bookmarks[1].fetch_time, datetime.strptime("2015-07-01 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual( @@ -348,7 +341,7 @@ def test_save_bookmarks(self): self.assertEqual( bookmarks[1].post_time, datetime.strptime("2015-06-18T09:48:31Z", "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual( @@ -374,7 +367,7 @@ def test_update_bookmarks(self): """Ensure that when saving a Bookmark that already exists, we update it.""" account = Account.objects.get(pk=1) - fetch_time = datetime.utcnow().replace(tzinfo=timezone.utc) + fetch_time = datetime.now(tz=UTC) # Add a Bookmark into the DB before we fetch anything. bookmark = BookmarkFactory( @@ -412,7 +405,7 @@ def test_update_bookmarks(self): self.assertEqual( bookmarks[1].fetch_time, datetime.strptime("2015-07-01 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) @@ -424,7 +417,7 @@ def test_update_bookmarks(self): def test_no_update_bookmarks(self): """Ensure that if no values have changed, we don't update a bookmark.""" account = Account.objects.get(pk=1) - fetch_time = datetime.utcnow().replace(tzinfo=timezone.utc) + fetch_time = datetime.now(tz=UTC) # Add a Bookmark into the DB before we fetch anything. BookmarkFactory( diff --git a/tests/pinboard/test_management_commands.py b/tests/pinboard/test_management_commands.py index ff3cace..27cd1d6 100644 --- a/tests/pinboard/test_management_commands.py +++ b/tests/pinboard/test_management_commands.py @@ -1,4 +1,3 @@ -# coding: utf-8 from io import StringIO from unittest.mock import patch diff --git a/tests/pinboard/test_models.py b/tests/pinboard/test_models.py index 1e590f5..9f21e54 100644 --- a/tests/pinboard/test_models.py +++ b/tests/pinboard/test_models.py @@ -1,5 +1,4 @@ -# coding: utf-8 -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from django.db import IntegrityError from django.test import TestCase @@ -108,7 +107,7 @@ def test_ordering(self): account = AccountFactory(username="billy") post_time = datetime.strptime( "2015-01-01 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.utc) + ).replace(tzinfo=UTC) bookmark_1 = BookmarkFactory(account=account, post_time=post_time) bookmark_2 = BookmarkFactory( account=account, post_time=(post_time + timedelta(days=1)) @@ -196,7 +195,7 @@ def test_post_year(self): class BookmarkNextPrevTestCase(TestCase): def setUp(self): dt = datetime.strptime("2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ) account = AccountFactory() diff --git a/tests/pinboard/test_templatetags.py b/tests/pinboard/test_templatetags.py index a5f794d..c5bd623 100644 --- a/tests/pinboard/test_templatetags.py +++ b/tests/pinboard/test_templatetags.py @@ -1,5 +1,4 @@ -# coding: utf-8 -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from django.test import TestCase @@ -43,7 +42,7 @@ def setUp(self): self.bookmarks_1 = BookmarkFactory.create_batch(6, account=account_1) self.bookmarks_2 = BookmarkFactory.create_batch(6, account=account_2) - post_time = datetime(2015, 3, 18, 12, 0, 0).replace(tzinfo=timezone.utc) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) self.bookmarks_1[3].post_time = post_time self.bookmarks_1[3].save() self.bookmarks_1[5].is_private = True diff --git a/tests/twitter/test_fetch.py b/tests/twitter/test_fetch.py index 8a4eaff..620c0bc 100644 --- a/tests/twitter/test_fetch.py +++ b/tests/twitter/test_fetch.py @@ -17,13 +17,18 @@ class FetchTwitterTestCase(TestCase): def make_response_body(self): "Makes the JSON response to a call to the API" - json_file = open("%s%s" % ("tests/twitter/fixtures/api/", self.api_fixture)) - json_data = json_file.read() - json_file.close() - return json_data + with open(f"tests/twitter/fixtures/api/{self.api_fixture}") as f: + json_data = f.read() + return json_data def add_response( - self, body, status=200, querystring={}, match_querystring=False, method="GET" + self, + body, + *, + status=200, + querystring=None, + match_querystring=False, + method="GET", ): """Add a Twitter API response. @@ -35,13 +40,12 @@ def add_response( a querystring. method -- 'GET' or 'POST'. """ - url = "%s/%s.json" % (self.api_url, self.api_call) + querystring = {} if querystring is None else querystring + url = f"{self.api_url}/{self.api_call}.json" if len(querystring): - qs = "&".join( - "%s=%s" % (key, querystring[key]) for key in querystring.keys() - ) - url = "%s?%s" % (url, qs) + qs = "&".join(f"{key}={querystring[key]}" for key in querystring) + url = f"{url}?{qs}" method = responses.POST if method == "POST" else responses.GET diff --git a/tests/twitter/test_fetch_fetchers.py b/tests/twitter/test_fetch_fetchers.py index b8a4c08..dd53f7f 100644 --- a/tests/twitter/test_fetch_fetchers.py +++ b/tests/twitter/test_fetch_fetchers.py @@ -302,10 +302,9 @@ def test_fetches_multiple_pages_for_count(self): qs["count"] = 100 self.add_response(body=body, querystring=qs, match_querystring=True) - with patch.object(FetchTweetsRecent, "_save_results"): - with patch("time.sleep"): - RecentTweetsFetcher(screen_name="jill").fetch(count=700) - self.assertEqual(4, len(responses.calls)) + with patch.object(FetchTweetsRecent, "_save_results"), patch("time.sleep"): + RecentTweetsFetcher(screen_name="jill").fetch(count=700) + self.assertEqual(4, len(responses.calls)) class FavoriteTweetsFetcherTestCase(TwitterFetcherTestCase): @@ -497,14 +496,12 @@ def test_fetches_multiple_pages_for_count(self): qs["count"] = 100 self.add_response(body=body, querystring=qs, match_querystring=True) - with patch.object(FetchTweetsFavorite, "_save_results"): - with patch("time.sleep"): - FavoriteTweetsFetcher(screen_name="jill").fetch(count=700) - self.assertEqual(4, len(responses.calls)) + with patch.object(FetchTweetsFavorite, "_save_results"), patch("time.sleep"): + FavoriteTweetsFetcher(screen_name="jill").fetch(count=700) + self.assertEqual(4, len(responses.calls)) class UsersFetcherTestCase(TwitterFetcherTestCase): - api_fixture = "users_lookup.json" api_call = "users/lookup" @@ -587,14 +584,12 @@ def test_fetches_multiple_pages(self): qs["user_id"] = "%2C".join(map(str, ids[-50:])) self.add_response(body=body, querystring=qs, match_querystring=True) - with patch.object(FetchUsers, "_save_results"): - with patch("time.sleep"): - UsersFetcher(screen_name="jill").fetch(ids) - self.assertEqual(4, len(responses.calls)) + with patch.object(FetchUsers, "_save_results"), patch("time.sleep"): + UsersFetcher(screen_name="jill").fetch(ids) + self.assertEqual(4, len(responses.calls)) class TweetsFetcherTestCase(TwitterFetcherTestCase): - api_fixture = "tweets.json" api_call = "statuses/lookup" @@ -689,7 +684,7 @@ def test_fetches_multiple_pages(self): ids = [id for id in range(1, 351)] body = json.dumps([{"id": id} for id in range(1, 100)]) - for n in range(3): + for _n in range(3): # First time, add ids 1-100. Then 101-200. Then 201-300. # start = n * 100 # end = (n + 1) * 100 @@ -704,14 +699,12 @@ def test_fetches_multiple_pages(self): # qs['id'] = ','.join(map(str, ids[-50:])) self.add_response(body=body, method="POST") - with patch.object(FetchTweets, "_save_results"): - with patch("time.sleep"): - TweetsFetcher(screen_name="jill").fetch(ids) - self.assertEqual(4, len(responses.calls)) + with patch.object(FetchTweets, "_save_results"), patch("time.sleep"): + TweetsFetcher(screen_name="jill").fetch(ids) + self.assertEqual(4, len(responses.calls)) class VerifyFetcherTestCase(TwitterFetcherTestCase): - api_fixture = "verify_credentials.json" api_call = "account/verify_credentials" @@ -783,7 +776,6 @@ def test_saves_users(self): class FetchVerifyTestCase(FetchTwitterTestCase): - api_fixture = "verify_credentials.json" api_call = "account/verify_credentials" @@ -807,7 +799,7 @@ def test_fetch_for_account_creates(self, fetch_avatar): self.assertEqual(new_user.screen_name, "philgyford") self.assertEqual(1, len(responses.calls)) self.assertEqual( - "%s/%s.json" % (self.api_url, "account/verify_credentials"), + f"{self.api_url}/account/verify_credentials.json", responses.calls[0].request.url, ) @@ -831,7 +823,7 @@ def test_fetch_for_account_updates(self, fetch_avatar): self.assertEqual(updated_user.screen_name, "philgyford") self.assertEqual(1, len(responses.calls)) self.assertEqual( - "%s/%s.json" % (self.api_url, "account/verify_credentials"), + f"{self.api_url}/account/verify_credentials.json", responses.calls[0].request.url, ) @@ -849,7 +841,7 @@ def test_fetch_for_account_fails(self): self.assertEqual(result["account"], "Unsaved Account") self.assertEqual(1, len(responses.calls)) self.assertEqual( - "%s/%s.json" % (self.api_url, "account/verify_credentials"), + f"{self.api_url}/account/verify_credentials.json", responses.calls[0].request.url, ) self.assertTrue("Could not authenticate you" in result["messages"][0]) @@ -876,7 +868,7 @@ def setUp(self): self.video = VideoFactory( twitter_id=66666666, - image_url="https://pbs.twimg.com/ext_tw_video_thumb/740282905369444352/pu/img/zyxwvutsrqponml.jpg", # noqa: E501 + image_url="https://pbs.twimg.com/ext_tw_video_thumb/740282905369444352/pu/img/zyxwvutsrqponml.jpg", image_file="", mp4_file="", ) @@ -984,8 +976,7 @@ def test_saves_downloaded_image_file(self, download): FetchFiles()._fetch_and_save_file(self.image, "image") self.assertEqual( self.image.image_file.name, - "twitter/media/%s/%s/%s" - % ( + "twitter/media/{}/{}/{}".format( temp_filepath[-4:-2], temp_filepath[-2:], os.path.basename(temp_filepath), @@ -1003,8 +994,7 @@ def test_saves_downloaded_mp4_file(self, download): FetchFiles()._fetch_and_save_file(self.animated_gif, "mp4") self.assertEqual( self.animated_gif.mp4_file.name, - "twitter/media/%s/%s/%s" - % ( + "twitter/media/{}/{}/{}".format( temp_filepath[-4:-2], temp_filepath[-2:], os.path.basename(temp_filepath), diff --git a/tests/twitter/test_fetch_savers.py b/tests/twitter/test_fetch_savers.py index 62b05b8..419c8b4 100644 --- a/tests/twitter/test_fetch_savers.py +++ b/tests/twitter/test_fetch_savers.py @@ -1,7 +1,7 @@ import json import os import tempfile -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from unittest.mock import patch @@ -23,7 +23,7 @@ class TweetSaverTestCase(FetchTwitterTestCase): # fixture to something much shorter, and easier to test with. api_fixture = "tweets.json" - def make_tweet(self, is_private=False): + def make_tweet(self, *, is_private=False): self.fetch_time = datetime_now() # Get the JSON for a single tweet. @@ -69,7 +69,7 @@ def test_saves_correct_tweet_data(self): self.assertEqual( tweet.post_time, datetime.strptime("2015-08-06 19:42:59", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual(tweet.favorite_count, 2) @@ -325,7 +325,7 @@ def test_saves_videos(self): self.assertEqual(video.twitter_id, 1234567890) self.assertEqual( video.image_url, - "https://pbs.twimg.com/ext_tw_video_thumb/661601811007188992/pu/img/gcxHGl7EA08a-Gps.jpg", # noqa: E501 + "https://pbs.twimg.com/ext_tw_video_thumb/661601811007188992/pu/img/gcxHGl7EA08a-Gps.jpg", ) self.assertEqual(video.large_w, 640) self.assertEqual(video.large_h, 360) @@ -340,11 +340,11 @@ def test_saves_videos(self): self.assertEqual(video.aspect_ratio, "16:9") self.assertEqual( video.dash_url, - "https://video.twimg.com/ext_tw_video/661601811007188992/pu/pl/K0pVjBgnc5BI_4e5.mpd", # noqa: E501 + "https://video.twimg.com/ext_tw_video/661601811007188992/pu/pl/K0pVjBgnc5BI_4e5.mpd", ) self.assertEqual( video.xmpeg_url, - "https://video.twimg.com/ext_tw_video/661601811007188992/pu/pl/K0pVjBgnc5BI_4e5.m3u8", # noqa: E501 + "https://video.twimg.com/ext_tw_video/661601811007188992/pu/pl/K0pVjBgnc5BI_4e5.m3u8", ) @@ -382,14 +382,14 @@ def test_saves_gifs(self): class UserSaverTestCase(FetchTwitterTestCase): - api_fixture = "verify_credentials.json" - def make_user_data(self, custom={}): + def make_user_data(self, custom=None): """Get the JSON for a single user. custom is a dict of attributes to override on the default data. eg, {'protected': True} """ + custom = {} if custom is None else custom raw_json = self.make_response_body() user_data = json.loads(raw_json) for key, value in custom.items(): @@ -408,7 +408,6 @@ def make_user_object(self, user_data, download): @freeze_time("2015-08-14 12:00:00", tz_offset=-8) def test_saves_correct_user_data(self): - user_data = self.make_user_data() user = self.make_user_object(user_data) @@ -421,7 +420,7 @@ def test_saves_correct_user_data(self): self.assertEqual( user.created_at, datetime.strptime("2006-11-15 16:55:59", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ), ) self.assertEqual(user.description, "Good. Good to Firm in places.") diff --git a/tests/twitter/test_ingest_v1.py b/tests/twitter/test_ingest_v1.py index 6302dbb..3f26336 100644 --- a/tests/twitter/test_ingest_v1.py +++ b/tests/twitter/test_ingest_v1.py @@ -8,28 +8,25 @@ class Version1TweetIngesterTestCase(TestCase): - # A sample file of the format we'd get in a Twitter archive. ingest_fixture = "tests/twitter/fixtures/ingest/v1/2015_08.js" def get_tweet_data(self): "Returns the JSON tweet data, as text, from the fixture." - file = open(self.ingest_fixture) - tweet_data = file.read() - file.close() - return tweet_data + with open(self.ingest_fixture) as f: + tweet_data = f.read() + return tweet_data def test_raises_error_with_invalid_dir(self): - with patch("os.path.isdir", return_value=False): - with self.assertRaises(IngestError): - Version1TweetIngester().ingest(directory="/bad/dir") + with patch("os.path.isdir", return_value=False), self.assertRaises(IngestError): + Version1TweetIngester().ingest(directory="/bad/dir") def test_raises_error_with_empty_dir(self): "If no .js files are found, raises IngestError" - with patch("os.path.isdir", return_value=True): - with patch("ditto.twitter.ingest.Version1TweetIngester", file_count=0): - with self.assertRaises(IngestError): - Version1TweetIngester().ingest(directory="/bad/dir") + with patch("os.path.isdir", return_value=True), patch( + "ditto.twitter.ingest.Version1TweetIngester", file_count=0 + ), self.assertRaises(IngestError): + Version1TweetIngester().ingest(directory="/bad/dir") # All the below have a similar structure to mock out file-related functions. # Here's what's happening: diff --git a/tests/twitter/test_ingest_v2.py b/tests/twitter/test_ingest_v2.py index c9f409c..33b6fae 100644 --- a/tests/twitter/test_ingest_v2.py +++ b/tests/twitter/test_ingest_v2.py @@ -49,7 +49,7 @@ def test_saves_user_data(self): "id_str": "12552", "screen_name": "philgyford", "name": "Phil Gyford", - "profile_image_url_https": "https://pbs.twimg.com/profile_images/1167616130/james_200208_300x300.jpg", # NOQA: E501 + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1167616130/james_200208_300x300.jpg", "verified": False, "ditto_note": ( "This user data was compiled from separate parts of a " diff --git a/tests/twitter/test_management_commands.py b/tests/twitter/test_management_commands.py index 952af42..8335424 100644 --- a/tests/twitter/test_management_commands.py +++ b/tests/twitter/test_management_commands.py @@ -1,4 +1,3 @@ -# coding: utf-8 from io import StringIO from unittest.mock import patch @@ -25,7 +24,6 @@ def tearDown(self): class FetchTwitterTweetsArgs(FetchTwitterArgs): - fetcher_class_path = ( "ditto.twitter.management.commands.fetch_twitter_tweets.RecentTweetsFetcher" ) @@ -60,7 +58,6 @@ def test_with_number(self): class FetchTwitterFavoritesArgs(FetchTwitterArgs): - fetcher_class_path = "ditto.twitter.management.commands.fetch_twitter_favorites.FavoriteTweetsFetcher" # noqa: E501 def test_fail_with_no_args(self): @@ -117,7 +114,6 @@ def tearDown(self): class FetchTwitterTweetsOutput(FetchTwitterOutput): - fetch_method_path = "ditto.twitter.management.commands.fetch_twitter_tweets.RecentTweetsFetcher.fetch" # noqa: E501 def test_success_output(self): @@ -152,7 +148,6 @@ def test_error_output(self): class FetchTwitterFavoritesOutput(FetchTwitterOutput): - fetch_method_path = "ditto.twitter.management.commands.fetch_twitter_favorites.FavoriteTweetsFetcher.fetch" # noqa: E501 def test_success_output(self): @@ -190,7 +185,6 @@ def test_error_output(self): class FetchTwitterAccountsOutput(FetchTwitterOutput): - fetch_method_path = ( "ditto.twitter.management.commands.fetch_twitter_accounts.VerifyFetcher.fetch" ) @@ -242,7 +236,7 @@ class ImportTweetsVersion1(TestCase): def setUp(self): self.patcher = patch( - "ditto.twitter.management.commands.import_twitter_tweets.Version1TweetIngester.ingest" # noqa: E501 + "ditto.twitter.management.commands.import_twitter_tweets.Version1TweetIngester.ingest" ) self.ingest_mock = self.patcher.start() self.out = StringIO() @@ -270,7 +264,7 @@ class ImportTweetsVersion2(TestCase): def setUp(self): self.patcher = patch( - "ditto.twitter.management.commands.import_twitter_tweets.Version2TweetIngester.ingest" # noqa: E501 + "ditto.twitter.management.commands.import_twitter_tweets.Version2TweetIngester.ingest" ) self.ingest_mock = self.patcher.start() self.out = StringIO() @@ -281,11 +275,12 @@ def tearDown(self): def test_fails_with_invalid_directory(self): "Test fails with invalid directory" - with patch("os.path.isdir", return_value=False): - with self.assertRaises(CommandError): - call_command( - "import_twitter_tweets", path="/wrong/path", archive_version="v2" - ) + with patch("os.path.isdir", return_value=False), self.assertRaises( + CommandError + ): + call_command( + "import_twitter_tweets", path="/wrong/path", archive_version="v2" + ) def test_calls_ingest_method(self): "Calls correct class and method" diff --git a/tests/twitter/test_models.py b/tests/twitter/test_models.py index 08f3a13..d334719 100644 --- a/tests/twitter/test_models.py +++ b/tests/twitter/test_models.py @@ -1,6 +1,5 @@ -# coding: utf-8 import os -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import Mock, patch import responses @@ -24,17 +23,15 @@ class AccountTestCase(TestCase): - api_url = "https://api.twitter.com/1.1" api_fixture = "tests/twitter/fixtures/api/verify_credentials.json" def make_verify_credentials_body(self): "Makes the JSON response to a call to verify_credentials" - json_file = open(self.api_fixture) - json_data = json_file.read() - json_file.close() - return json_data + with open(self.api_fixture) as f: + json_data = f.read() + return json_data def add_response(self, body, call, status=200): """Add a Twitter API response. @@ -47,7 +44,7 @@ def add_response(self, body, call, status=200): """ responses.add( responses.GET, - "%s/%s.json" % (self.api_url, call), + f"{self.api_url}/{call}.json", status=status, match_querystring=False, body=body, @@ -89,7 +86,7 @@ def test_creates_user(self, fetch_avatar): self.assertEqual(account.user.screen_name, "philgyford") self.assertEqual(1, len(responses.calls)) self.assertEqual( - "%s/%s.json" % (self.api_url, "account/verify_credentials"), + f"{self.api_url}/account/verify_credentials.json", responses.calls[0].request.url, ) @@ -114,7 +111,7 @@ def test_update_user_does_nothing_with_no_credentials(self): ) # Not saving (as that generates another request): account = AccountFactory.build(user=None) - result = account.updateUserFromTwitter() + result = account.update_user_from_twitter() self.assertEqual(result, False) self.assertEqual(0, len(responses.calls)) @@ -131,14 +128,14 @@ def test_update_user_updates_user(self, fetch_avatar): # Not saving (as that generates another request): account = AccountWithCredentialsFactory.build(user=None) - result = account.updateUserFromTwitter() + result = account.update_user_from_twitter() self.assertTrue(result["success"]) self.assertIsInstance(result["user"], User) self.assertIsInstance(account.user, User) self.assertEqual(account.user.screen_name, "philgyford") self.assertEqual(1, len(responses.calls)) self.assertEqual( - "%s/%s.json" % (self.api_url, "account/verify_credentials"), + f"{self.api_url}/account/verify_credentials.json", responses.calls[0].request.url, ) @@ -153,10 +150,10 @@ def test_update_user_returns_error_message(self): # Not saving (as that generates another request): account = AccountWithCredentialsFactory.build(user=None) - result = account.updateUserFromTwitter() + result = account.update_user_from_twitter() self.assertEqual(1, len(responses.calls)) self.assertEqual( - "%s/%s.json" % (self.api_url, "account/verify_credentials"), + f"{self.api_url}/account/verify_credentials.json.json", responses.calls[0].request.url, ) self.assertFalse(result["success"]) @@ -195,7 +192,7 @@ def test_media_type(self): def test_ordering(self): """Multiple accounts are sorted by time_created ascending""" - time_now = datetime.utcnow().replace(tzinfo=timezone.utc) + time_now = datetime.now(tz=UTC) photo_1 = PhotoFactory(time_created=time_now - timedelta(minutes=1)) PhotoFactory(time_created=time_now) photos = Media.objects.all() @@ -414,7 +411,7 @@ def test_get_absolute_url(self): def test_ordering(self): """Multiple tweets are sorted by post_time descending""" - time_now = datetime.utcnow().replace(tzinfo=timezone.utc) + time_now = datetime.now(tz=UTC) TweetFactory(post_time=time_now - timedelta(minutes=1)) tweet_2 = TweetFactory(post_time=time_now) tweets = Tweet.objects.all() @@ -629,7 +626,7 @@ def test_post_year(self): class TweetNextPrevTestCase(TestCase): def setUp(self): dt = datetime.strptime("2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.utc + tzinfo=UTC ) user = UserFactory() diff --git a/tests/twitter/test_templatetags.py b/tests/twitter/test_templatetags.py index 8ec56a8..c0ebd65 100644 --- a/tests/twitter/test_templatetags.py +++ b/tests/twitter/test_templatetags.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from django.test import TestCase @@ -96,7 +96,7 @@ def setUp(self): self.tweets_2 = TweetFactory.create_batch(2, user=user_2) self.tweets_3 = TweetFactory.create_batch(2, user=user_3) - post_time = datetime(2015, 3, 18, 12, 0, 0).replace(tzinfo=timezone.utc) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) self.tweets_1[0].post_time = post_time self.tweets_1[0].save() self.tweets_2[1].post_time = post_time + timedelta(hours=1) @@ -137,7 +137,7 @@ def setUp(self): self.tweets[0].user.is_private = True self.tweets[0].user.save() - post_time = datetime(2015, 3, 18, 12, 0, 0).replace(tzinfo=timezone.utc) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) self.tweets[0].post_time = post_time self.tweets[0].save() self.tweets[1].post_time = post_time + timedelta(hours=1) diff --git a/tests/twitter/test_utils.py b/tests/twitter/test_utils.py index e2e2111..c46a2c5 100644 --- a/tests/twitter/test_utils.py +++ b/tests/twitter/test_utils.py @@ -1,4 +1,3 @@ -# coding: utf-8 import json from django.test import TestCase @@ -7,15 +6,13 @@ class HtmlifyTestCase(TestCase): - # Define in child classes. api_fixture = None - def getJson(self, fixture): - json_file = open("%s%s" % ("tests/twitter/fixtures/api/", fixture)) - json_data = json.loads(json_file.read()) - json_file.close() - return json_data + def get_json(self, fixture): + with open(f"tests/twitter/fixtures/api/{fixture}") as f: + json_data = json.loads(f.read()) + return json_data class HtmlifyDescriptionTestCase(HtmlifyTestCase): @@ -24,7 +21,7 @@ class HtmlifyDescriptionTestCase(HtmlifyTestCase): api_fixture = "user_with_description.json" def test_htmlify_description(self): - description_html = htmlify_description(self.getJson(self.api_fixture)) + description_html = htmlify_description(self.get_json(self.api_fixture)) self.assertEqual( description_html, ( @@ -42,7 +39,7 @@ class HtmlifyTweetEntitiesTestCase(HtmlifyTestCase): def test_links_urls(self): "Makes 'urls' entities into clickable links." api_fixture = "tweet_with_entities.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertTrue( ( 'with ' @@ -93,7 +90,7 @@ def test_links_users_with_entities(self): def test_links_hashtags(self): "Makes 'hashtags' entities into clickable #links." api_fixture = "tweet_with_entities.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertTrue( ( ' " @@ -126,7 +123,7 @@ def test_links_substringed_hashtags(self): def test_links_symbols(self): api_fixture = "tweet_with_symbols.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertEqual( tweet_html, ( @@ -141,11 +138,10 @@ def test_links_symbols(self): class HtmlifyTweetTestCase(HtmlifyTestCase): - api_fixture = "tweet_with_entities.json" def setUp(self): - self.json_data = self.getJson(self.api_fixture) + self.json_data = self.get_json(self.api_fixture) def test_linebreaks(self): "Turns linebreaks into
s" @@ -171,7 +167,7 @@ def test_handles_display_text_range_str(self): It should be able to cope with that. """ api_fixture = "tweet_with_display_text_range_str.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertEqual( tweet_html, ( @@ -191,7 +187,7 @@ def test_handles_entities_indicies_str(self): It should be able to cope with that. """ api_fixture = "tweet_with_entities_indices_str.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertEqual( tweet_html, ( @@ -212,7 +208,7 @@ def test_urls_in_archived_tweets(self): tweets. """ api_fixture = "tweet_from_archive_2006.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertEqual( tweet_html, ( @@ -226,7 +222,7 @@ def test_urls_in_archived_tweets(self): def test_urls_with_no_media(self): """The 'entities' element has no 'media'.""" api_fixture = "tweet_with_entities_2.json" - tweet_html = htmlify_tweet(self.getJson(api_fixture)) + tweet_html = htmlify_tweet(self.get_json(api_fixture)) self.assertTrue( ( 'created.
Date: Mon, 11 Dec 2023 18:37:50 +0000 Subject: [PATCH 04/20] Change pyupgrade to target 3.9+ --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bfec2e8..adf55b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,4 +37,4 @@ repos: rev: v3.15.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py39-plus] From 5b15bb2339b38ffa38acf42517fe5671d8bd1fd7 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 18:45:31 +0000 Subject: [PATCH 05/20] Add Django 5 to tests etc --- .github/workflows/tests.yml | 15 +++-- CHANGELOG.md | 112 +++++++++++++++++++++++------------- docs/introduction.rst | 2 +- setup.py | 3 +- tox.ini | 10 ++-- 5 files changed, 91 insertions(+), 51 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 11f3395..5399b40 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,19 +16,26 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12-dev"] - django-version: ["3.2", "4.1", "4.2", "main"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13-dev"] + django-version: ["3.2", "4.1", "4.2", "5.0", "main"] exclude: # Django 5.0 isn't compatible with python < 3.10 + - python-version: "3.9" + django-version: "5.0" - python-version: "3.9" django-version: "main" - python-version: "3.11" django-version: "3.2" - - python-version: "3.12-dev" + - python-version: "3.12" + django-version: "3.2" + - python-version: "3.12" + django-version: "4.1" + + - python-version: "3.13-dev" django-version: "3.2" - - python-version: "3.12-dev" + - python-version: "3.13-dev" django-version: "4.1" steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 910f721..da94fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,141 +1,159 @@ # Changelog (Django Ditto) + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## [Unreleased] -- None +### Added +- Added support for Django 5.0 + +### Changed + +- Switched from using flake8 and black for linting and formatting to ruff ## [3.1.0] - 2023-06-20 ### Added -- Add support for python 3.12. (Nothing changed, other than some assertions in tests.) +- Add support for python 3.12. (Nothing changed, other than some assertions in tests.) ## [3.0.1] - 2023-05-22 ### Fixed + - Fix the `search_fields` in `lastfm`'s `AccountAdmin`. ### Added -- Added general tests for Admin classes +- Added general tests for Admin classes ## [3.0.0] - 2023-05-09 ### Removed + - Dropped support for Python 3.7 and 3.8. (A bit eager but hopefully it's not a problem.) - Dropped support for Django 4.0. (Nothing changed, but removed from tests.) ### Added + - Added support for Django 4.2. (Nothing changed, but added to tests.) ### Changed + - No longer requires pytz as a dependency. - Update dependencies including allowing for django-taggit 4.0 - ## [2.3.0] - 2022-11-28 ### Added + - Add migration for `pinboard.BookmarkTag.slug` that addes `allow_unicode=True`. (It's the default in django-taggit and suddenly it's generating a new migration.) - Add support for python 3.11. (Nothing changed, but added to tests.) ### Changed -- Remove black version requirements +- Remove black version requirements ## [2.2.0] - 2022-08-08 ### Added + - Added support for Django 4.1. - Add the latest master branch Django to the tox testing matrix. - Added `.pre-commit-config.yaml` ### Changed + - Update dependencies, including requiring django-taggit >= 3.0.0. - Update included Bootstrap CSS from 4.6.0 to 4.6.2. - Update development project dependencies ### Fixed -- Errors related to tags when using Django 4.1 (#238) +- Errors related to tags when using Django 4.1 (#238) ## [2.1.1] - 2022-03-25 ### Fixed -- Include `.map` files in `core/static/ditto-core/`. They were missing which causes issues when running `collectstatic`. +- Include `.map` files in `core/static/ditto-core/`. They were missing which causes issues when running `collectstatic`. ## [2.1.0] - 2022-03-24 ### Removed + - The no-longer-used `ditto/core/context_processors.py` file has been removed (deprecated in v0.6.0). ### Added + - Optional `start` and `end` arguments to the `fetch_flickr_photos` management command. (Thanks @garrettc) ### Fixed + - Handle error when importing Flickr data (such as tags) created by a Flickr user who has since been deleted. (#234, thanks @garrettc) - Handle error when importing Flickr photos that don't have all expected image sizes. (#235, thanks @garrettc) - Allow for the `User.realname` property to be blank because the Flickr API doesn't return that field at all for some users (#237) - ## [2.0.0] - 2022-02-14 ### Removed + - **Backwards incompatible:** Drop support for Django 2.2 and 3.1. ### Changed + - **Backwards incompatible:** Requires django-taggit >= v2.0.0 (It changed how `TaggableManager` sets tags: https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst#200-2021-11-14 ) ### Added + - Add support for Django 4.0 (#223) - Add support for Python 3.10 (#225) - Add support for importing the "new" (2019-onwwards) format of downloadable Twitter archive (#229). - ## [1.4.2] - 2021-10-22 ### Changed + - Update python dependences in `devproject/Pipfile` and `docs/Pipfile`. - Update included Bootstrap CSS and JS files to v4.6.0. - Change README and CHANGELOG from `.rst` to `.md` format. ### Fixed -- Remove hard-coded Flickr ID in `fetch_flickr_account_user` management command (thanks @garrettc). +- Remove hard-coded Flickr ID in `fetch_flickr_account_user` management command (thanks @garrettc). ## [1.4.1] - 2021-08-24 ### Fixed + - Replace use of deprecated `django.conf.urls.url()` method. - Fix path to `img/default_avatar.png` static file. - ## [1.4.0] - 2021-04-07 ### Added + - Allow for use of Django 3.2; update devproject to use it. ### Changed -- Change status in `setup.py` from Beta to Production/Stable. +- Change status in `setup.py` from Beta to Production/Stable. ## [1.3.9] - 2020-12-23 ### Fixed -- Add missing update migration for Pinboard. +- Add missing update migration for Pinboard. ## [1.3.0] - 2020-11-20 ### Changed + - Update Flickr Photo models, fetchers and imagegenerators to include the X-Large 3K, X-Large 4K, X-Large 5K and X-Large 6K sizes. A data migration will populate the model fields for any Photos that have this size data already fetched in their raw API data. It does the same for the Medium 800, Large, Large 1600, and Large 2048 sizes too. @@ -145,77 +163,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update python dependencies, including Pillow v8, freezegun v1, and django-debug-toolbar v3. ### Fixed -- Fix ordering of Tweets posted at the same time (as in some threads). +- Fix ordering of Tweets posted at the same time (as in some threads). ## [1.2.0] - 2020-08-22 ### Fixed -- Fix Factory Boy imports and allow for Factory Boy v3 +- Fix Factory Boy imports and allow for Factory Boy v3 ## [1.1.0] - 2020-08-10 ### Changed -- Move all static files from `static/` to `static/ditto-core/`. +- Move all static files from `static/` to `static/ditto-core/`. ## [1.0.0] - 2020-08-10 ### Changed + - About time we went to version 1. - Allow for use of Django 3.1, django-taggit 1.3, pillow 7.2. - Update included Bootstrap CSS from 4.5.0 to 4.5.2. - ## [0.11.2] ### Changed + - Add flake8 to tests - Upgrade Bootstrap CSS and JS from v4.4 to v4.5, and jQuery from 3.4.1 to 3.5.1. ### Fixed -- Fix BOM/encoding error when fetching data from the Pinboard API +- Fix BOM/encoding error when fetching data from the Pinboard API ## [0.11.1] ### Changed + - Update devproject python dependencies - Update Bootstrap CSS and JS from 4.1.1 to 4.4.1, Popper to 1.16, and jQuery from 3.3.1 to 3.4.1. ### Fixed -- Fix character encoding issue with fetched Last.fm data +- Fix character encoding issue with fetched Last.fm data ## [0.11.0] ### Removed -- Dropped support for Django 2.1. Now requires either Django 2.2 or 3.0. +- Dropped support for Django 2.1. Now requires either Django 2.2 or 3.0. ## [0.10.3] ### Changed + - Allow for use of Pillow 6.2 as well as 6.1. - Update devproject dependencies. - ## [0.10.2] ### Fixed -- Fix error when fetching Flickr photos that have location data, as Flickr currently isn't returning `place_id` or `woeid` fields. (0.10.1 was a poor attempt at fixing this.) +- Fix error when fetching Flickr photos that have location data, as Flickr currently isn't returning `place_id` or `woeid` fields. (0.10.1 was a poor attempt at fixing this.) ## [0.10.0] ### Removed + - Drop support for Django 2.0 ### Added + - Add support for Django 2.2 ### Changed + - No new features, but upgrades of requirements. - Switch from django-sortedm2m to django-sorted-m2m, which contains Django 2.2 support. - Upgrade pillow requirement from 4.3 to 6.1. @@ -223,135 +246,142 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgrade Bootstrap in devproject from 4.1 to 4.3. - Upgrade Django used in devproject from 2.1 to 2.2. - ## [0.9.0] ### Added + - Add support for Django 2.1 (no code changes required). ### Changed -- Change to use pipenv, instead of pip, for devproject requirements. +- Change to use pipenv, instead of pip, for devproject requirements. ## [0.8.1] ### Changed + - Upgrade Twython requirement to 3.7.0 from custom version. - Upgrade Django used in devproject from 2.0.4 to 2.0.5. - Upgrade included Bootstrap from v4.1.0 to v4.1.1. - ## [0.8.0] ### Added + - Add optional settings for the date and time formats used in default templates. ### Changed + - Upgrade Bootstrap from v4-beta-3 to v4.1. - Upgraded Twython (and added a migration) to fix formatting of some Tweets. - ## [0.7.6] ### Fixed + - Fix an error when fetching a Flickr user's data if they didn't have 'location' or 'timezone' data set. ## [0.7.5] ### Fixed -- Fix display of images (Twitter avatars and images, Flickr avatars and images) in the Django Admin pages. +- Fix display of images (Twitter avatars and images, Flickr avatars and images) in the Django Admin pages. ## [0.7.4] ### Changed + - When fetching Twitter favorites, fetches the extended version of the tweets and includes entities. - Temporarily use a different specific version of Twython (see README or docs). - ## [0.7.3] ### Changed + - Fetches extended tweet data when fetching recent tweets. - Temporarily requires manual inclusion of a specific version of Twython in your project's pip requirements (see README or docs). ### Fixed -- Handles tweets longer than 255 characters without Postgres complaining (SQLite quietly carried on). +- Handles tweets longer than 255 characters without Postgres complaining (SQLite quietly carried on). ## [0.7.2] ### Fixed -- Add missing migrations for Flickr and Last.fm. +- Add missing migrations for Flickr and Last.fm. ## [0.7.1] ### Changed -- For Last.fm template tags, rely on the `FIRST_DAY_OF_WEEK` Django setting, instead of the now unused `DITTO_WEEK_START` setting. +- For Last.fm template tags, rely on the `FIRST_DAY_OF_WEEK` Django setting, instead of the now unused `DITTO_WEEK_START` setting. ## [0.7.0] ### Removed + - Drop support for Django 1.10. ### Added + - Add support Django 2.0 ### Changed -- Upgrade Bootstrap from v4 beta 1 to v4 beta 3. +- Upgrade Bootstrap from v4 beta 1 to v4 beta 3. ## [0.6.5] ### Changed -- Increase the maximum length of a Twitter User's display name to 50 characters. +- Increase the maximum length of a Twitter User's display name to 50 characters. ## [0.6.4] ### Added -- The Flickr `day_photos` template tag can now fetch photos taken on a particular day, as well as posted on a day. +- The Flickr `day_photos` template tag can now fetch photos taken on a particular day, as well as posted on a day. ## [0.6.3] ### Added -- The Last.fm template tags for the top albums, artists and tracks can now display the top list for a week, as well as day, month and year. +- The Last.fm template tags for the top albums, artists and tracks can now display the top list for a week, as well as day, month and year. ## [0.6.2] ### Added -- Added the `popular_bookmark_tags` template tag to the `pinboard` app. +- Added the `popular_bookmark_tags` template tag to the `pinboard` app. ## [0.6.1] ### Fixed -- Fix bug when importing Flickr photos and there's already a tag with a different `slug` but the same `name`. +- Fix bug when importing Flickr photos and there's already a tag with a different `slug` but the same `name`. ## [0.6.0] ### Deprecated -- The ditto context\_processor is no longer required, and now does nothing. - Replaced its `enabled_apps` with a `get_enabled_apps` template tag. +- The ditto context_processor is no longer required, and now does nothing. + Replaced its `enabled_apps` with a `get_enabled_apps` template tag. ## [0.5.2] ### Fixed -- Fix screenshots URL in README and documentation. +- Fix screenshots URL in README and documentation. ## [0.5.0] ### Added + - Add Bootstrap and jQuery to make navigation bar collapsible ### Changed + - Upgrade Bootstrap to v4-beta #189, #180 - Test it works in Django 1.11 #185 - Label the `core` app as `ditto_core` #186 diff --git a/docs/introduction.rst b/docs/introduction.rst index fe8eb6a..70a0d8b 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -4,7 +4,7 @@ Introduction A collection of Django apps for copying things from third-party sites and services. If something doesn't make sense, `email Phil Gyford `_ and I'll try and clarify it. -Requires Python 3.9 to 3.12, and Django 3.2,, 4.1 and 4.2. +Requires Python 3.9 to 3.12, and Django 3.2, 4.1, 4.2, and 5.0. `See screenshots of a site using the supplied templates. `_ diff --git a/setup.py b/setup.py index 52a3df1..2074e86 100644 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_author_email(): ], dependency_links=[], tests_require=tests_require, - extras_require={"dev": dev_require + ["Django>=4.1,<4.3"], "test": tests_require}, + extras_require={"dev": dev_require + ["Django>=4.1,<5.0"], "test": tests_require}, include_package_data=True, license=get_license(), description=( @@ -116,6 +116,7 @@ def get_author_email(): "Framework :: Django :: 3.2", "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", diff --git a/tox.ini b/tox.ini index 09fc490..42b6e11 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,10 @@ minversion = 1.8 envlist = ruff - py39-django{32,41,42,main} - py310-django{32,41,42,main} - py311-django{41,42,main} - py312-django{42,main} + py39-django{32,41,42} + py310-django{32,41,42,50,main} + py311-django{41,42,50,main} + py312-django{42,50,main} [gh-actions] ; Maps GitHub Actions python version numbers to tox env vars: @@ -23,6 +23,7 @@ DJANGO = 3.2: django32 4.1: django41 4.2: django42 + 4.0: django50 main: djangomain ; Dependencies and ENV things we need for all environments: @@ -38,6 +39,7 @@ deps = django32: Django >= 3.2, < 3.3 django41: Django >= 4.0, < 4.2 django42: Django >= 4.1, < 4.3 + django50: Django >= 5.0, < 5.1 djangomain: https://github.com/django/django/archive/master.tar.gz extras = {[base]extras} From c4c60ec8739a0fc87adeb925c9ff90ca2222bab5 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 18:49:40 +0000 Subject: [PATCH 06/20] Allowed use of Pillow v10 and django-imagekit v5.0 --- CHANGELOG.md | 1 + setup.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da94fa2..6b8891b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Allowed use of Pillow v10 and django-imagekit v5.0 - Switched from using flake8 and black for linting and formatting to ruff ## [3.1.0] - 2023-06-20 diff --git a/setup.py b/setup.py index 2074e86..f5b7592 100644 --- a/setup.py +++ b/setup.py @@ -69,16 +69,16 @@ def get_author_email(): sys.exit() dev_require = [ - "django-debug-toolbar>=2.0,<5.0", + "django-debug-toolbar", "python-dotenv", "ruff", "unittest-parametrize", ] tests_require = dev_require + [ - "factory-boy>=2.12.0,<4.0", - "freezegun>=0.3.12,<2.0", - "responses>=0.10.7,<1.0", + "factory-boy", + "freezegun", + "responses", "coverage[toml]", ] @@ -87,11 +87,13 @@ def get_author_email(): version=get_version(), packages=["ditto"], install_requires=[ - "django-imagekit>=4.0,<4.2", + "django-imagekit>=4.0,<6.0", "django-sortedm2m>=3.0.0,<3.2", + # django-taggit 5.0 removes support for Django 3.2, so update this when we + # drop Django 3.2: "django-taggit>=3.0.0,<5.0", "flickrapi>=2.4,<2.5", - "pillow>=8.0.0,<10.0", + "pillow>=9.0.0,<11.0", "twitter-text-python>=1.1.1,<1.2", "twython>=3.7.0,<3.10", ], From 2baf4bc4683fd72a6b99dafe2f5f42d29ee78e77 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 18:55:04 +0000 Subject: [PATCH 07/20] Fix error in setup.py --- README.md | 9 +++------ setup.py | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ec39cb7..6991f9f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,12 @@ -Django Ditto -============ +# Django Ditto +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-ditto) [![image](https://github.com/philgyford/django-ditto/actions/workflows/tests.yml/badge.svg)](https://github.com/philgyford/django-ditto/actions/workflows/tests.yml "Tests status") [![codecov](https://codecov.io/gh/philgyford/django-ditto/branch/main/graph/badge.svg?token=T7TMMDS64A)](https://codecov.io/gh/philgyford/django-ditto) [![image](https://readthedocs.org/projects/django-ditto/badge/?version=stable)](https://django-ditto.readthedocs.io/en/stable/?badge=stable "Documentation status") -[![Code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) - A collection of Django apps for copying things from third-party sites and services. Requires Python 3.9 to 3.12, and Django 3.2, 4.1, or 4.2. [Read the documentation.](http://django-ditto.readthedocs.io/en/latest/) diff --git a/setup.py b/setup.py index f5b7592..f605423 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,8 @@ def get_entity(package, entity): eg, get_entity('ditto', 'version') returns `__version__` value in `__init__.py`. """ - with open(os.path.join(package, "__init__.py")).read() as init_py: + with open(os.path.join(package, "__init__.py")) as f: + init_py = f.read() find = "__%s__ = ['\"]([^'\"]+)['\"]" % entity return re.search(find, init_py).group(1) From 390cc126ebe8ff0044fdad140fb988240a2b2d72 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:03:17 +0000 Subject: [PATCH 08/20] Tweaks to setup.py --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f605423..f4604df 100644 --- a/setup.py +++ b/setup.py @@ -100,7 +100,7 @@ def get_author_email(): ], dependency_links=[], tests_require=tests_require, - extras_require={"dev": dev_require + ["Django>=4.1,<5.0"], "test": tests_require}, + extras_require={"dev": dev_require + ["Django>=4.1,<=5.0"], "test": tests_require}, include_package_data=True, license=get_license(), description=( @@ -123,7 +123,7 @@ def get_author_email(): "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Programming Language :: Python", + "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From 3bf72cce0d26a04a35fbc3fa3347d7ee45af899f Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:14:14 +0000 Subject: [PATCH 09/20] Correct Django versions --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 42b6e11..e623cd2 100644 --- a/tox.ini +++ b/tox.ini @@ -37,8 +37,8 @@ setenv = [testenv] deps = django32: Django >= 3.2, < 3.3 - django41: Django >= 4.0, < 4.2 - django42: Django >= 4.1, < 4.3 + django41: Django >= 4.1, < 4.2 + django42: Django >= 4.2, < 4.3 django50: Django >= 5.0, < 5.1 djangomain: https://github.com/django/django/archive/master.tar.gz extras = From 5ef929de402c25aa4378532103009cc29fc273ee Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:14:58 +0000 Subject: [PATCH 10/20] Fix use of datetime.UTC vs datetime.timezone.UTC Former is only from python 3.11 onwards --- ditto/core/utils/__init__.py | 6 +-- ditto/flickr/fetch/savers.py | 4 +- ditto/flickr/models.py | 39 +++++++++---------- ditto/flickr/templatetags/ditto_flickr.py | 6 +-- ditto/lastfm/fetch.py | 6 ++- ditto/pinboard/fetch.py | 6 +-- ditto/pinboard/templatetags/ditto_pinboard.py | 6 +-- ditto/twitter/fetch/savers.py | 4 +- ditto/twitter/templatetags/ditto_twitter.py | 10 ++--- tests/core/test_utils.py | 6 +-- tests/flickr/test_fetch.py | 4 +- tests/flickr/test_fetch_fetchers.py | 4 +- tests/flickr/test_fetch_filesfetchers.py | 6 ++- tests/flickr/test_fetch_savers.py | 26 ++++++------- tests/flickr/test_models.py | 20 +++++----- tests/flickr/test_templatetags.py | 6 +-- tests/lastfm/test_fetch.py | 8 ++-- tests/pinboard/test_fetch.py | 16 ++++---- tests/pinboard/test_models.py | 6 +-- tests/pinboard/test_templatetags.py | 4 +- tests/twitter/test_fetch_savers.py | 6 +-- tests/twitter/test_models.py | 8 ++-- tests/twitter/test_templatetags.py | 6 +-- 23 files changed, 108 insertions(+), 105 deletions(-) diff --git a/ditto/core/utils/__init__.py b/ditto/core/utils/__init__.py index 0fec75f..970a9fa 100644 --- a/ditto/core/utils/__init__.py +++ b/ditto/core/utils/__init__.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import datetime, timezone from django.db.models import Count from django.utils.html import strip_tags @@ -34,14 +34,14 @@ def datetime_now(): """Just returns a datetime object for now in UTC, with UTC timezone. Because I was doing this a lot in various places. """ - return datetime.now(tz=UTC) + return datetime.now(tz=timezone.UTC) def datetime_from_str(s): """A shortcut for making a UTC datetime from a string like '2015-08-11 12:00:00'. """ - return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC) + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.UTC) def get_annual_item_counts(qs, field_name="post_year"): diff --git a/ditto/flickr/fetch/savers.py b/ditto/flickr/fetch/savers.py index c5a8f80..0dc2820 100644 --- a/ditto/flickr/fetch/savers.py +++ b/ditto/flickr/fetch/savers.py @@ -1,6 +1,6 @@ import contextlib import json -from datetime import UTC, datetime +from datetime import datetime, timezone from zoneinfo import ZoneInfo from django.db.utils import IntegrityError @@ -44,7 +44,7 @@ def _unixtime_to_datetime(self, api_time): """Change a text unixtime from the API to a datetime with timezone. api_time is a string or int like "1093459273". """ - return datetime.fromtimestamp(int(api_time), tz=UTC) + return datetime.fromtimestamp(int(api_time), tz=timezone.UTC) class UserSaver(SaveUtilsMixin): diff --git a/ditto/flickr/models.py b/ditto/flickr/models.py index a0a51ed..c59513f 100644 --- a/ditto/flickr/models.py +++ b/ditto/flickr/models.py @@ -813,25 +813,6 @@ def public_photos(self): return self.photos.filter(is_private=False) -def avatar_upload_path(obj, filename): - "Make path under MEDIA_ROOT where avatar file will be saved." - # If NSID is '35034346050@N01', get '35034346050' - nsid = obj.nsid[: obj.nsid.index("@")] - - # If NSID is '35034346050@N01' - # then, 'flickr/60/50/35034346050/avatars/avatar_name.jpg' - return "/".join( - [ - app_settings.FLICKR_DIR_BASE, - nsid[-4:-2], - nsid[-2:], - obj.nsid.replace("@", ""), - "avatars", - filename, - ] - ) - - class User(TimeStampedModelMixin, DiffModelMixin, models.Model): nsid = models.CharField( null=False, @@ -876,7 +857,7 @@ class User(TimeStampedModelMixin, DiffModelMixin, models.Model): ) avatar = models.ImageField( - upload_to=avatar_upload_path, null=False, blank=True, default="" + upload_to="avatar_upload_path", null=False, blank=True, default="" ) objects = models.Manager() @@ -918,3 +899,21 @@ def original_icon_url(self): ) else: return "https://www.flickr.com/images/buddyicon.gif" + + def avatar_upload_path(self, filename): + "Make path under MEDIA_ROOT where avatar file will be saved." + # If NSID is '35034346050@N01', get '35034346050' + nsid = self.nsid[: self.nsid.index("@")] + + # If NSID is '35034346050@N01' + # then, 'flickr/60/50/35034346050/avatars/avatar_name.jpg' + return "/".join( + [ + app_settings.FLICKR_DIR_BASE, + nsid[-4:-2], + nsid[-2:], + self.nsid.replace("@", ""), + "avatars", + filename, + ] + ) diff --git a/ditto/flickr/templatetags/ditto_flickr.py b/ditto/flickr/templatetags/ditto_flickr.py index 57e22ab..90faf31 100644 --- a/ditto/flickr/templatetags/ditto_flickr.py +++ b/ditto/flickr/templatetags/ditto_flickr.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import datetime, timezone from datetime import time as datetime_time from django import template @@ -44,8 +44,8 @@ def day_photos(date, nsid=None, time="post_time"): "`time` must be either 'post_time' or " "'taken_time', not '%s'." % time ) - start = datetime.combine(date, datetime_time.min).replace(tzinfo=UTC) - end = datetime.combine(date, datetime_time.max).replace(tzinfo=UTC) + start = datetime.combine(date, datetime_time.min).replace(tzinfo=timezone.UTC) + end = datetime.combine(date, datetime_time.max).replace(tzinfo=timezone.UTC) photos = Photo.public_photo_objects if time == "taken_time": diff --git a/ditto/lastfm/fetch.py b/ditto/lastfm/fetch.py index 1bf03f7..fcd5187 100644 --- a/ditto/lastfm/fetch.py +++ b/ditto/lastfm/fetch.py @@ -2,7 +2,7 @@ import json import time import urllib -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import requests @@ -256,7 +256,9 @@ def _save_scrobble(self, scrobble, fetch_time): ) # Unixtime to datetime object: - scrobble_time = datetime.fromtimestamp(int(scrobble["date"]["uts"]), tz=UTC) + scrobble_time = datetime.fromtimestamp( + int(scrobble["date"]["uts"]), tz=timezone.UTC + ) scrobble_obj, created = Scrobble.objects.update_or_create( account=self.account, diff --git a/ditto/pinboard/fetch.py b/ditto/pinboard/fetch.py index 6aae976..589c779 100644 --- a/ditto/pinboard/fetch.py +++ b/ditto/pinboard/fetch.py @@ -1,6 +1,6 @@ import json import urllib -from datetime import UTC, datetime +from datetime import datetime, timezone import requests @@ -177,7 +177,7 @@ def _parse_response(self, fetch_type, json_text): # Time string to object: bookmark["time"] = datetime.strptime( bookmark["time"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=UTC) + ).replace(tzinfo=timezone.UTC) # 'yes'/'no' to booleans: bookmark["shared"] = bookmark["shared"] == "yes" bookmark["toread"] = bookmark["toread"] == "yes" @@ -249,7 +249,7 @@ def fetch(self, post_date, username=None): FetchError if the date format is invalid. """ try: - dt = datetime.strptime(post_date, "%Y-%m-%d").astimezone(UTC) + dt = datetime.strptime(post_date, "%Y-%m-%d").astimezone(timezone.UTC) except ValueError as err: raise FetchError("Invalid date format ('%s')" % post_date) from err else: diff --git a/ditto/pinboard/templatetags/ditto_pinboard.py b/ditto/pinboard/templatetags/ditto_pinboard.py index 96698cd..e5170d4 100644 --- a/ditto/pinboard/templatetags/ditto_pinboard.py +++ b/ditto/pinboard/templatetags/ditto_pinboard.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime, time +from datetime import datetime, time, timezone from django import template @@ -34,8 +34,8 @@ def day_bookmarks(date, account=None): Keyword arguments: account -- An account username, 'philgyford', or None to fetch for all. """ - start = datetime.combine(date, time.min).replace(tzinfo=UTC) - end = datetime.combine(date, time.max).replace(tzinfo=UTC) + start = datetime.combine(date, time.min).replace(tzinfo=timezone.UTC) + end = datetime.combine(date, time.max).replace(tzinfo=timezone.UTC) bookmarks = Bookmark.public_objects.filter(post_time__range=[start, end]) if account is not None: bookmarks = bookmarks.filter(account__username=account) diff --git a/ditto/twitter/fetch/savers.py b/ditto/twitter/fetch/savers.py index 2550c29..5e9f249 100644 --- a/ditto/twitter/fetch/savers.py +++ b/ditto/twitter/fetch/savers.py @@ -1,6 +1,6 @@ import json import os -from datetime import UTC, datetime +from datetime import datetime, timezone from django.conf import settings from django.core.files import File @@ -29,7 +29,7 @@ def _api_time_to_datetime(self, api_time, time_format="%a %b %d %H:%M:%S +0000 % """Change a text datetime from the API to a datetime with timezone. api_time is a string like 'Wed Nov 15 16:55:59 +0000 2006'. """ - return datetime.strptime(api_time, time_format).replace(tzinfo=UTC) + return datetime.strptime(api_time, time_format).replace(tzinfo=timezone.UTC) class UserSaver(SaveUtilsMixin): diff --git a/ditto/twitter/templatetags/ditto_twitter.py b/ditto/twitter/templatetags/ditto_twitter.py index 7cc9df3..59725ca 100644 --- a/ditto/twitter/templatetags/ditto_twitter.py +++ b/ditto/twitter/templatetags/ditto_twitter.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime, time +from datetime import datetime, time, timezone from django import template @@ -57,8 +57,8 @@ def day_tweets(date, screen_name=None): screen_name -- A Twitter user's screen_name. If not supplied, we fetch all public Tweets. """ - start = datetime.combine(date, time.min).replace(tzinfo=UTC) - end = datetime.combine(date, time.max).replace(tzinfo=UTC) + start = datetime.combine(date, time.min).replace(tzinfo=timezone.UTC) + end = datetime.combine(date, time.max).replace(tzinfo=timezone.UTC) tweets = Tweet.public_tweet_objects.filter(post_time__range=[start, end]) if screen_name is not None: tweets = tweets.filter(user__screen_name=screen_name) @@ -81,8 +81,8 @@ def day_favorites(date, screen_name=None): screen_name -- A Twitter user's screen_name. If not supplied, we fetch all public Tweets. """ - start = datetime.combine(date, time.min).replace(tzinfo=UTC) - end = datetime.combine(date, time.max).replace(tzinfo=UTC) + start = datetime.combine(date, time.min).replace(tzinfo=timezone.UTC) + end = datetime.combine(date, time.max).replace(tzinfo=timezone.UTC) if screen_name is None: tweets = Tweet.public_favorite_objects.filter(post_time__range=[start, end]) else: diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index b185201..d8655fb 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import datetime, timezone import responses from django.test import TestCase @@ -12,7 +12,7 @@ class DatetimeNowTestCase(TestCase): @freeze_time("2015-08-14 12:00:00", tz_offset=-8) def test_datetime_now(self): - self.assertEqual(datetime_now(), datetime.now(tz=UTC)) + self.assertEqual(datetime_now(), datetime.now(tz=timezone.UTC)) class DatetimeFromStrTestCase(TestCase): @@ -20,7 +20,7 @@ def test_datetime_from_str(self): s = "2015-08-12 12:00:00" self.assertEqual( datetime_from_str(s), - datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC), + datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.UTC), ) diff --git a/tests/flickr/test_fetch.py b/tests/flickr/test_fetch.py index fb85cea..b933cae 100644 --- a/tests/flickr/test_fetch.py +++ b/tests/flickr/test_fetch.py @@ -1,5 +1,5 @@ import json -from datetime import UTC, datetime +from datetime import datetime, timezone from urllib.parse import parse_qs, quote_plus from zoneinfo import ZoneInfo @@ -33,7 +33,7 @@ def test_api_datetime_to_datetime_custom_tz(self): def test_unixtime_to_datetime(self): api_time = "1093459273" time1 = SaveUtilsMixin()._unixtime_to_datetime(api_time) - time2 = datetime.fromtimestamp(int(api_time), tz=UTC) + time2 = datetime.fromtimestamp(int(api_time), tz=timezone.UTC) self.assertEqual(time1, time2) diff --git a/tests/flickr/test_fetch_fetchers.py b/tests/flickr/test_fetch_fetchers.py index 8bc4e0c..6e4db30 100644 --- a/tests/flickr/test_fetch_fetchers.py +++ b/tests/flickr/test_fetch_fetchers.py @@ -1,7 +1,7 @@ import json import os import tempfile -from datetime import UTC, datetime +from datetime import datetime, timezone from unittest.mock import call, patch from django.test import override_settings @@ -214,7 +214,7 @@ def test_deleted_user(self): self.assertEqual(user.photos_count, 0) dt = datetime.strptime("1970-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ) self.assertEqual(user.photos_first_date, dt) self.assertEqual(user.photos_first_date_taken, dt) diff --git a/tests/flickr/test_fetch_filesfetchers.py b/tests/flickr/test_fetch_filesfetchers.py index 96c5f29..2cf7eae 100644 --- a/tests/flickr/test_fetch_filesfetchers.py +++ b/tests/flickr/test_fetch_filesfetchers.py @@ -1,6 +1,6 @@ import os import tempfile -from datetime import UTC, datetime +from datetime import datetime, timezone from unittest.mock import call, patch from django.test import TestCase, override_settings @@ -19,7 +19,9 @@ def setUp(self): self.photo_1 = PhotoFactory(title="p1", original_file="p1.jpg", user=user) - the_time = datetime.strptime("2015-08-14", "%Y-%m-%d").replace(tzinfo=UTC) + the_time = datetime.strptime("2015-08-14", "%Y-%m-%d").replace( + tzinfo=timezone.UTC + ) # Needs a taken_time for testing file save path: # post_time will put them in order. diff --git a/tests/flickr/test_fetch_savers.py b/tests/flickr/test_fetch_savers.py index 47fb639..921c7ec 100644 --- a/tests/flickr/test_fetch_savers.py +++ b/tests/flickr/test_fetch_savers.py @@ -1,5 +1,5 @@ import json -from datetime import UTC, datetime +from datetime import datetime, timezone from decimal import Decimal from unittest.mock import patch from zoneinfo import ZoneInfo @@ -22,7 +22,7 @@ def make_user_object(self, user_data): """ "Creates/updates a User from API data, then fetches that User from the DB and returns it. """ - fetch_time = datetime.now(tz=UTC) + fetch_time = datetime.now(tz=timezone.UTC) UserSaver().save_user(user_data, fetch_time) return User.objects.get(nsid="35034346050@N01") @@ -33,7 +33,7 @@ def test_saves_correct_user_data(self): user_data = self.load_fixture("people.getInfo") user = self.make_user_object(user_data["person"]) - self.assertEqual(user.fetch_time, datetime.now(tz=UTC)) + self.assertEqual(user.fetch_time, datetime.now(tz=timezone.UTC)) self.assertEqual(user.raw, json.dumps(user_data["person"])) self.assertEqual(user.nsid, "35034346050@N01") self.assertTrue(user.is_pro) @@ -48,12 +48,12 @@ def test_saves_correct_user_data(self): self.assertEqual(user.photos_count, 2876) self.assertEqual( user.photos_first_date, - datetime.fromtimestamp(1093459273, tz=UTC), + datetime.fromtimestamp(1093459273, tz=timezone.UTC), ) self.assertEqual( user.photos_first_date_taken, datetime.strptime("1956-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual(user.photos_views, 227227) @@ -97,7 +97,7 @@ def make_photo_object(self, photo_data): def make_photo_data(self): """Makes the dict of data that photo_save() expects, based on API data.""" return { - "fetch_time": datetime.now(tz=UTC), + "fetch_time": datetime.now(tz=timezone.UTC), "user_obj": UserFactory(nsid="35034346050@N01"), "info": self.load_fixture("photos.getInfo")["photo"], "exif": self.load_fixture("photos.getExif")["photo"], @@ -119,11 +119,11 @@ def test_saves_correct_photo_data(self, save_tags): ) self.assertFalse(photo.is_private) self.assertEqual(photo.summary, "Some test HTML. And another paragraph.") - self.assertEqual(photo.fetch_time, datetime.now(tz=UTC)) + self.assertEqual(photo.fetch_time, datetime.now(tz=timezone.UTC)) self.assertEqual( photo.post_time, datetime.strptime("2016-03-28 16:05:05", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual(photo.latitude, Decimal("51.967930000")) @@ -148,7 +148,7 @@ def test_saves_correct_photo_data(self, save_tags): self.assertEqual( photo.last_update_time, datetime.strptime("2016-04-04 13:21:11", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual( @@ -375,7 +375,7 @@ def make_photoset_object(self, photoset_data): def make_photoset_data(self): """Makes the dict of data that photo_save() expects, based on API data.""" return { - "fetch_time": datetime.now(tz=UTC), + "fetch_time": datetime.now(tz=timezone.UTC), "user_obj": UserFactory(nsid="35034346050@N01"), "photoset": self.load_fixture("photosets.getList")["photosets"]["photoset"][ 0 @@ -388,7 +388,7 @@ def test_saves_correct_photoset_data(self): photoset_data = self.make_photoset_data() photoset = self.make_photoset_object(photoset_data) - self.assertEqual(photoset.fetch_time, datetime.now(tz=UTC)) + self.assertEqual(photoset.fetch_time, datetime.now(tz=timezone.UTC)) self.assertEqual(photoset.user, photoset_data["user_obj"]) self.assertEqual(photoset.flickr_id, 72157665648859705) @@ -403,13 +403,13 @@ def test_saves_correct_photoset_data(self): self.assertEqual( photoset.last_update_time, datetime.strptime("2016-03-28 16:02:03", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual( photoset.flickr_created_time, datetime.strptime("2016-03-08 19:37:04", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual(photoset.raw, json.dumps(photoset_data["photoset"])) diff --git a/tests/flickr/test_models.py b/tests/flickr/test_models.py index bca1d7c..e86d473 100644 --- a/tests/flickr/test_models.py +++ b/tests/flickr/test_models.py @@ -1,5 +1,5 @@ import os -from datetime import UTC, datetime +from datetime import datetime, timezone from unittest.mock import Mock, patch from django.db import IntegrityError @@ -136,13 +136,13 @@ def test_photoset_ordering(self): title="Earliest", flickr_created_time=datetime.strptime( "2016-04-07 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) PhotosetFactory( title="Latest", flickr_created_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) photosets = Photoset.objects.all() self.assertEqual(photosets[0].title, "Latest") @@ -214,13 +214,13 @@ def test_ordering(self): title="Earliest", post_time=datetime.strptime( "2016-04-07 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) PhotoFactory( title="Latest", post_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) photos = Photo.objects.all() self.assertEqual(photos[0].title, "Latest") @@ -473,7 +473,7 @@ def setUp(self): user=UserFactory(nsid="123456@N01"), taken_time=datetime.strptime( "2015-08-14 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) def tearDown(self): @@ -556,14 +556,14 @@ def setUp(self): user=user, post_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) self.private_photo = PhotoFactory( user=user, is_private=True, post_time=datetime.strptime( "2016-04-09 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) # Photo by a different user: user_2 = UserFactory() @@ -572,13 +572,13 @@ def setUp(self): user=user_2, post_time=datetime.strptime( "2016-04-10 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) self.photo_2 = PhotoFactory( user=user, post_time=datetime.strptime( "2016-04-11 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC), + ).replace(tzinfo=timezone.UTC), ) def test_next_public_by_post_time(self): diff --git a/tests/flickr/test_templatetags.py b/tests/flickr/test_templatetags.py index 045e24d..4717789 100644 --- a/tests/flickr/test_templatetags.py +++ b/tests/flickr/test_templatetags.py @@ -1,4 +1,4 @@ -from datetime import UTC, date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from django.test import TestCase @@ -73,8 +73,8 @@ def setUp(self): self.photos_1 = PhotoFactory.create_batch(2, user=user_1) self.photos_2 = PhotoFactory.create_batch(3, user=user_2) - taken_time = datetime(2014, 3, 18, 12, 0, 0, tzinfo=UTC) - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) + taken_time = datetime(2014, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) self.photos_1[0].taken_time = taken_time self.photos_1[0].post_time = post_time self.photos_1[0].save() diff --git a/tests/lastfm/test_fetch.py b/tests/lastfm/test_fetch.py index 9af7f41..75030ce 100644 --- a/tests/lastfm/test_fetch.py +++ b/tests/lastfm/test_fetch.py @@ -1,5 +1,5 @@ import json -from datetime import UTC, datetime +from datetime import datetime, timezone from unittest.mock import call, patch import responses @@ -214,7 +214,7 @@ def test_sends_from_time_correctly_for_recent(self): ScrobbleFactory( post_time=datetime.strptime( "2015-08-11 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC) + ).replace(tzinfo=timezone.UTC) ) # Timestamp for 2015-08-11 12:00:00 UTC: self.add_recent_tracks_response(from_time=1439294400) @@ -389,7 +389,7 @@ def test_updates_existing_scrobbles(self): track = TrackFactory(artist=artist, slug="make+up") post_time = datetime.strptime( "2016-09-22 09:23:33", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC) + ).replace(tzinfo=timezone.UTC) scrobble = ScrobbleFactory( account=self.account, track=track, post_time=post_time ) @@ -417,7 +417,7 @@ def test_sets_scrobble_data(self): self.assertEqual( scrobble.post_time, datetime.strptime("2016-09-22 09:23:33", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) json_data = self.load_fixture("user_getrecenttracks") diff --git a/tests/pinboard/test_fetch.py b/tests/pinboard/test_fetch.py index 1b4d7ab..c0fba6c 100644 --- a/tests/pinboard/test_fetch.py +++ b/tests/pinboard/test_fetch.py @@ -1,5 +1,5 @@ import json -from datetime import UTC, datetime +from datetime import datetime, timezone from unittest.mock import patch import responses @@ -275,7 +275,7 @@ def test_fetch_json_parsing(self): self.assertEqual( bookmarks_data[0]["time"], datetime.strptime("2015-06-18T09:48:31Z", "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) @@ -303,7 +303,7 @@ def test_save_bookmarks(self): Bookmark objects. """ account = Account.objects.get(pk=1) - fetch_time = datetime.now(tz=UTC) + fetch_time = datetime.now(tz=timezone.UTC) bookmarks_from_json = self.get_bookmarks_from_json() bookmarks_data = bookmarks_from_json["bookmarks"] @@ -325,7 +325,7 @@ def test_save_bookmarks(self): self.assertEqual( bookmarks[1].fetch_time, datetime.strptime("2015-07-01 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual( @@ -341,7 +341,7 @@ def test_save_bookmarks(self): self.assertEqual( bookmarks[1].post_time, datetime.strptime("2015-06-18T09:48:31Z", "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual( @@ -367,7 +367,7 @@ def test_update_bookmarks(self): """Ensure that when saving a Bookmark that already exists, we update it.""" account = Account.objects.get(pk=1) - fetch_time = datetime.now(tz=UTC) + fetch_time = datetime.now(tz=timezone.UTC) # Add a Bookmark into the DB before we fetch anything. bookmark = BookmarkFactory( @@ -405,7 +405,7 @@ def test_update_bookmarks(self): self.assertEqual( bookmarks[1].fetch_time, datetime.strptime("2015-07-01 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) @@ -417,7 +417,7 @@ def test_update_bookmarks(self): def test_no_update_bookmarks(self): """Ensure that if no values have changed, we don't update a bookmark.""" account = Account.objects.get(pk=1) - fetch_time = datetime.now(tz=UTC) + fetch_time = datetime.now(tz=timezone.UTC) # Add a Bookmark into the DB before we fetch anything. BookmarkFactory( diff --git a/tests/pinboard/test_models.py b/tests/pinboard/test_models.py index 9f21e54..fe6837b 100644 --- a/tests/pinboard/test_models.py +++ b/tests/pinboard/test_models.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from django.db import IntegrityError from django.test import TestCase @@ -107,7 +107,7 @@ def test_ordering(self): account = AccountFactory(username="billy") post_time = datetime.strptime( "2015-01-01 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=UTC) + ).replace(tzinfo=timezone.UTC) bookmark_1 = BookmarkFactory(account=account, post_time=post_time) bookmark_2 = BookmarkFactory( account=account, post_time=(post_time + timedelta(days=1)) @@ -195,7 +195,7 @@ def test_post_year(self): class BookmarkNextPrevTestCase(TestCase): def setUp(self): dt = datetime.strptime("2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ) account = AccountFactory() diff --git a/tests/pinboard/test_templatetags.py b/tests/pinboard/test_templatetags.py index c5bd623..92d1584 100644 --- a/tests/pinboard/test_templatetags.py +++ b/tests/pinboard/test_templatetags.py @@ -1,4 +1,4 @@ -from datetime import UTC, date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from django.test import TestCase @@ -42,7 +42,7 @@ def setUp(self): self.bookmarks_1 = BookmarkFactory.create_batch(6, account=account_1) self.bookmarks_2 = BookmarkFactory.create_batch(6, account=account_2) - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) self.bookmarks_1[3].post_time = post_time self.bookmarks_1[3].save() self.bookmarks_1[5].is_private = True diff --git a/tests/twitter/test_fetch_savers.py b/tests/twitter/test_fetch_savers.py index 419c8b4..e32fd20 100644 --- a/tests/twitter/test_fetch_savers.py +++ b/tests/twitter/test_fetch_savers.py @@ -1,7 +1,7 @@ import json import os import tempfile -from datetime import UTC, datetime +from datetime import datetime, timezone from decimal import Decimal from unittest.mock import patch @@ -69,7 +69,7 @@ def test_saves_correct_tweet_data(self): self.assertEqual( tweet.post_time, datetime.strptime("2015-08-06 19:42:59", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual(tweet.favorite_count, 2) @@ -420,7 +420,7 @@ def test_saves_correct_user_data(self): self.assertEqual( user.created_at, datetime.strptime("2006-11-15 16:55:59", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ), ) self.assertEqual(user.description, "Good. Good to Firm in places.") diff --git a/tests/twitter/test_models.py b/tests/twitter/test_models.py index d334719..5666bc1 100644 --- a/tests/twitter/test_models.py +++ b/tests/twitter/test_models.py @@ -1,5 +1,5 @@ import os -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from unittest.mock import Mock, patch import responses @@ -192,7 +192,7 @@ def test_media_type(self): def test_ordering(self): """Multiple accounts are sorted by time_created ascending""" - time_now = datetime.now(tz=UTC) + time_now = datetime.now(tz=timezone.UTC) photo_1 = PhotoFactory(time_created=time_now - timedelta(minutes=1)) PhotoFactory(time_created=time_now) photos = Media.objects.all() @@ -411,7 +411,7 @@ def test_get_absolute_url(self): def test_ordering(self): """Multiple tweets are sorted by post_time descending""" - time_now = datetime.now(tz=UTC) + time_now = datetime.now(tz=timezone.UTC) TweetFactory(post_time=time_now - timedelta(minutes=1)) tweet_2 = TweetFactory(post_time=time_now) tweets = Tweet.objects.all() @@ -626,7 +626,7 @@ def test_post_year(self): class TweetNextPrevTestCase(TestCase): def setUp(self): dt = datetime.strptime("2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=UTC + tzinfo=timezone.UTC ) user = UserFactory() diff --git a/tests/twitter/test_templatetags.py b/tests/twitter/test_templatetags.py index c0ebd65..bfb4797 100644 --- a/tests/twitter/test_templatetags.py +++ b/tests/twitter/test_templatetags.py @@ -1,4 +1,4 @@ -from datetime import UTC, date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from django.test import TestCase @@ -96,7 +96,7 @@ def setUp(self): self.tweets_2 = TweetFactory.create_batch(2, user=user_2) self.tweets_3 = TweetFactory.create_batch(2, user=user_3) - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) self.tweets_1[0].post_time = post_time self.tweets_1[0].save() self.tweets_2[1].post_time = post_time + timedelta(hours=1) @@ -137,7 +137,7 @@ def setUp(self): self.tweets[0].user.is_private = True self.tweets[0].user.save() - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) self.tweets[0].post_time = post_time self.tweets[0].save() self.tweets[1].post_time = post_time + timedelta(hours=1) From aaa17f543a7dfda7202ee7576d56c93a2c7585c0 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:19:57 +0000 Subject: [PATCH 11/20] Put upload path functions back where they were --- ditto/twitter/models.py | 88 ++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/ditto/twitter/models.py b/ditto/twitter/models.py index bca1646..a8bc5de 100644 --- a/ditto/twitter/models.py +++ b/ditto/twitter/models.py @@ -128,19 +128,6 @@ def has_credentials(self): ) -def media_upload_path(obj, filename): - "Make path under MEDIA_ROOT where original files will be saved." - - # eg get '12345678' from '12345678.jpg': - name = os.path.splitext(filename)[0] - - # If filename is '12345678.jpg': - # 'twitter/media/56/78/12345678.jpg' - return "/".join( - [app_settings.TWITTER_DIR_BASE, "media", name[-4:-2], name[-2:], filename] - ) - - class Media(TimeStampedModelMixin, models.Model): """A photo, video or animated GIF attached to a Tweet. @@ -223,10 +210,10 @@ class Media(TimeStampedModelMixin, models.Model): # END VIDEO-ONLY PROPERTIES image_file = models.ImageField( - upload_to=media_upload_path, null=False, blank=True, default="" + upload_to="upload_path", null=False, blank=True, default="" ) mp4_file = models.FileField( - upload_to=media_upload_path, + upload_to="upload_path", null=False, blank=True, default="", @@ -331,6 +318,18 @@ def video_mime_type(self): return mime_type + def upload_path(self, filename): + "Make path under MEDIA_ROOT where original files will be saved." + + # eg get '12345678' from '12345678.jpg': + name = os.path.splitext(filename)[0] + + # If filename is '12345678.jpg': + # 'twitter/media/56/78/12345678.jpg' + return "/".join( + [app_settings.TWITTER_DIR_BASE, "media", name[-4:-2], name[-2:], filename] + ) + def _image_url(self, size): """ Helper for the self.*_url() property methods. @@ -678,35 +677,6 @@ def _summary_source(self): return self.title -def avatar_upload_path(obj, filename): - """ - Make path under MEDIA_ROOT where avatar file will be saved. - - eg, if twitter_id is 12345678: - twitter/avatars/56/78/12345678/avatar_name.jpg - """ - - # We can't just join all these parts in one go, because if the ID - # isn't long enough to have two numbered directories, (ie, it's only - # 1 or 2 digits) then Django 1.8 creates a path with '//' rather than - # just ignoring that directory. - - # So this is a bit laborious: - - # 'twitter/avatars': - start = "/".join([app_settings.TWITTER_DIR_BASE, "avatars"]) - # '/78/12345678/avatar_name.jpg': - end = "/".join([str(obj.twitter_id)[-2:], str(obj.twitter_id), str(filename)]) - # The bit that will be empty for 1-2 digit IDs: - # '56': - middle = str(obj.twitter_id)[-4:-2] - - if middle: - return "/".join([start, middle, end]) - else: - return "/".join([start, end]) - - class User(TimeStampedModelMixin, DiffModelMixin, models.Model): """A Twitter user. We don't replicate all of the possible User attributes here, only enough @@ -794,7 +764,7 @@ class User(TimeStampedModelMixin, DiffModelMixin, models.Model): ) avatar = models.ImageField( - upload_to=avatar_upload_path, null=False, blank=True, default="" + upload_to="avatar_upload_path", null=False, blank=True, default="" ) favorites = models.ManyToManyField(Tweet, related_name="favoriting_users") @@ -822,6 +792,34 @@ def save(self, *args, **kwargs): def get_absolute_url(self): return reverse("twitter:user_detail", kwargs={"screen_name": self.screen_name}) + def avatar_upload_path(self, filename): + """ + Make path under MEDIA_ROOT where avatar file will be saved. + + eg, if twitter_id is 12345678: + twitter/avatars/56/78/12345678/avatar_name.jpg + """ + + # We can't just join all these parts in one go, because if the ID + # isn't long enough to have two numbered directories, (ie, it's only + # 1 or 2 digits) then Django 1.8 creates a path with '//' rather than + # just ignoring that directory. + + # So this is a bit laborious: + + # 'twitter/avatars': + start = "/".join([app_settings.TWITTER_DIR_BASE, "avatars"]) + # '/78/12345678/avatar_name.jpg': + end = "/".join([str(self.twitter_id)[-2:], str(self.twitter_id), str(filename)]) + # The bit that will be empty for 1-2 digit IDs: + # '56': + middle = str(self.twitter_id)[-4:-2] + + if middle: + return "/".join([start, middle, end]) + else: + return "/".join([start, end]) + def make_description_html(self): """Uses the raw JSON for the user to set self.description_html to a nice HTML version of the description. From 6f693b1ae816372f5fd8e528e8ff238013d1e5e6 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:26:15 +0000 Subject: [PATCH 12/20] Fix use of timezone.utc for python 3.9+ --- ditto/core/utils/__init__.py | 4 ++-- ditto/flickr/fetch/savers.py | 2 +- ditto/flickr/templatetags/ditto_flickr.py | 4 ++-- ditto/lastfm/fetch.py | 2 +- ditto/pinboard/fetch.py | 2 +- ditto/pinboard/templatetags/ditto_pinboard.py | 4 ++-- ditto/twitter/fetch/savers.py | 2 +- ditto/twitter/templatetags/ditto_twitter.py | 8 +++---- pyproject.toml | 4 +++- tests/core/test_utils.py | 4 ++-- tests/flickr/test_fetch.py | 2 +- tests/flickr/test_fetch_fetchers.py | 2 +- tests/flickr/test_fetch_filesfetchers.py | 2 +- tests/flickr/test_fetch_savers.py | 24 +++++++++---------- tests/flickr/test_models.py | 18 +++++++------- tests/flickr/test_templatetags.py | 4 ++-- tests/lastfm/test_fetch.py | 6 ++--- tests/pinboard/test_fetch.py | 14 +++++------ tests/pinboard/test_models.py | 4 ++-- tests/pinboard/test_templatetags.py | 2 +- tests/twitter/test_fetch_savers.py | 4 ++-- tests/twitter/test_models.py | 6 ++--- tests/twitter/test_templatetags.py | 4 ++-- 23 files changed, 65 insertions(+), 63 deletions(-) diff --git a/ditto/core/utils/__init__.py b/ditto/core/utils/__init__.py index 970a9fa..0a5b32c 100644 --- a/ditto/core/utils/__init__.py +++ b/ditto/core/utils/__init__.py @@ -34,14 +34,14 @@ def datetime_now(): """Just returns a datetime object for now in UTC, with UTC timezone. Because I was doing this a lot in various places. """ - return datetime.now(tz=timezone.UTC) + return datetime.now(tz=timezone.utc) def datetime_from_str(s): """A shortcut for making a UTC datetime from a string like '2015-08-11 12:00:00'. """ - return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.UTC) + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) def get_annual_item_counts(qs, field_name="post_year"): diff --git a/ditto/flickr/fetch/savers.py b/ditto/flickr/fetch/savers.py index 0dc2820..6bf7d64 100644 --- a/ditto/flickr/fetch/savers.py +++ b/ditto/flickr/fetch/savers.py @@ -44,7 +44,7 @@ def _unixtime_to_datetime(self, api_time): """Change a text unixtime from the API to a datetime with timezone. api_time is a string or int like "1093459273". """ - return datetime.fromtimestamp(int(api_time), tz=timezone.UTC) + return datetime.fromtimestamp(int(api_time), tz=timezone.utc) class UserSaver(SaveUtilsMixin): diff --git a/ditto/flickr/templatetags/ditto_flickr.py b/ditto/flickr/templatetags/ditto_flickr.py index 90faf31..4fe8554 100644 --- a/ditto/flickr/templatetags/ditto_flickr.py +++ b/ditto/flickr/templatetags/ditto_flickr.py @@ -44,8 +44,8 @@ def day_photos(date, nsid=None, time="post_time"): "`time` must be either 'post_time' or " "'taken_time', not '%s'." % time ) - start = datetime.combine(date, datetime_time.min).replace(tzinfo=timezone.UTC) - end = datetime.combine(date, datetime_time.max).replace(tzinfo=timezone.UTC) + start = datetime.combine(date, datetime_time.min).replace(tzinfo=timezone.utc) + end = datetime.combine(date, datetime_time.max).replace(tzinfo=timezone.utc) photos = Photo.public_photo_objects if time == "taken_time": diff --git a/ditto/lastfm/fetch.py b/ditto/lastfm/fetch.py index fcd5187..70cf92a 100644 --- a/ditto/lastfm/fetch.py +++ b/ditto/lastfm/fetch.py @@ -257,7 +257,7 @@ def _save_scrobble(self, scrobble, fetch_time): # Unixtime to datetime object: scrobble_time = datetime.fromtimestamp( - int(scrobble["date"]["uts"]), tz=timezone.UTC + int(scrobble["date"]["uts"]), tz=timezone.utc ) scrobble_obj, created = Scrobble.objects.update_or_create( diff --git a/ditto/pinboard/fetch.py b/ditto/pinboard/fetch.py index 589c779..1c1027b 100644 --- a/ditto/pinboard/fetch.py +++ b/ditto/pinboard/fetch.py @@ -177,7 +177,7 @@ def _parse_response(self, fetch_type, json_text): # Time string to object: bookmark["time"] = datetime.strptime( bookmark["time"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.UTC) + ).replace(tzinfo=timezone.utc) # 'yes'/'no' to booleans: bookmark["shared"] = bookmark["shared"] == "yes" bookmark["toread"] = bookmark["toread"] == "yes" diff --git a/ditto/pinboard/templatetags/ditto_pinboard.py b/ditto/pinboard/templatetags/ditto_pinboard.py index e5170d4..3c336a1 100644 --- a/ditto/pinboard/templatetags/ditto_pinboard.py +++ b/ditto/pinboard/templatetags/ditto_pinboard.py @@ -34,8 +34,8 @@ def day_bookmarks(date, account=None): Keyword arguments: account -- An account username, 'philgyford', or None to fetch for all. """ - start = datetime.combine(date, time.min).replace(tzinfo=timezone.UTC) - end = datetime.combine(date, time.max).replace(tzinfo=timezone.UTC) + start = datetime.combine(date, time.min).replace(tzinfo=timezone.utc) + end = datetime.combine(date, time.max).replace(tzinfo=timezone.utc) bookmarks = Bookmark.public_objects.filter(post_time__range=[start, end]) if account is not None: bookmarks = bookmarks.filter(account__username=account) diff --git a/ditto/twitter/fetch/savers.py b/ditto/twitter/fetch/savers.py index 5e9f249..3328855 100644 --- a/ditto/twitter/fetch/savers.py +++ b/ditto/twitter/fetch/savers.py @@ -29,7 +29,7 @@ def _api_time_to_datetime(self, api_time, time_format="%a %b %d %H:%M:%S +0000 % """Change a text datetime from the API to a datetime with timezone. api_time is a string like 'Wed Nov 15 16:55:59 +0000 2006'. """ - return datetime.strptime(api_time, time_format).replace(tzinfo=timezone.UTC) + return datetime.strptime(api_time, time_format).replace(tzinfo=timezone.utc) class UserSaver(SaveUtilsMixin): diff --git a/ditto/twitter/templatetags/ditto_twitter.py b/ditto/twitter/templatetags/ditto_twitter.py index 59725ca..94313fe 100644 --- a/ditto/twitter/templatetags/ditto_twitter.py +++ b/ditto/twitter/templatetags/ditto_twitter.py @@ -57,8 +57,8 @@ def day_tweets(date, screen_name=None): screen_name -- A Twitter user's screen_name. If not supplied, we fetch all public Tweets. """ - start = datetime.combine(date, time.min).replace(tzinfo=timezone.UTC) - end = datetime.combine(date, time.max).replace(tzinfo=timezone.UTC) + start = datetime.combine(date, time.min).replace(tzinfo=timezone.utc) + end = datetime.combine(date, time.max).replace(tzinfo=timezone.utc) tweets = Tweet.public_tweet_objects.filter(post_time__range=[start, end]) if screen_name is not None: tweets = tweets.filter(user__screen_name=screen_name) @@ -81,8 +81,8 @@ def day_favorites(date, screen_name=None): screen_name -- A Twitter user's screen_name. If not supplied, we fetch all public Tweets. """ - start = datetime.combine(date, time.min).replace(tzinfo=timezone.UTC) - end = datetime.combine(date, time.max).replace(tzinfo=timezone.UTC) + start = datetime.combine(date, time.min).replace(tzinfo=timezone.utc) + end = datetime.combine(date, time.max).replace(tzinfo=timezone.utc) if screen_name is None: tweets = Tweet.public_favorite_objects.filter(post_time__range=[start, end]) else: diff --git a/pyproject.toml b/pyproject.toml index 4db14cb..3b6f514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +[project] +requires-python = ">=3.9" + [tool.coverage.report] omit = ["*/migrations/*"] @@ -35,7 +38,6 @@ select = [ "RUF100", # unused-noqa "RUF200", # invalid-pyproject-toml ] -target-version = "py311" [tool.ruff.lint] ignore = [] diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index d8655fb..50fa9b8 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -12,7 +12,7 @@ class DatetimeNowTestCase(TestCase): @freeze_time("2015-08-14 12:00:00", tz_offset=-8) def test_datetime_now(self): - self.assertEqual(datetime_now(), datetime.now(tz=timezone.UTC)) + self.assertEqual(datetime_now(), datetime.now(tz=timezone.utc)) class DatetimeFromStrTestCase(TestCase): @@ -20,7 +20,7 @@ def test_datetime_from_str(self): s = "2015-08-12 12:00:00" self.assertEqual( datetime_from_str(s), - datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.UTC), + datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc), ) diff --git a/tests/flickr/test_fetch.py b/tests/flickr/test_fetch.py index b933cae..e1e2224 100644 --- a/tests/flickr/test_fetch.py +++ b/tests/flickr/test_fetch.py @@ -33,7 +33,7 @@ def test_api_datetime_to_datetime_custom_tz(self): def test_unixtime_to_datetime(self): api_time = "1093459273" time1 = SaveUtilsMixin()._unixtime_to_datetime(api_time) - time2 = datetime.fromtimestamp(int(api_time), tz=timezone.UTC) + time2 = datetime.fromtimestamp(int(api_time), tz=timezone.utc) self.assertEqual(time1, time2) diff --git a/tests/flickr/test_fetch_fetchers.py b/tests/flickr/test_fetch_fetchers.py index 6e4db30..3c0f903 100644 --- a/tests/flickr/test_fetch_fetchers.py +++ b/tests/flickr/test_fetch_fetchers.py @@ -214,7 +214,7 @@ def test_deleted_user(self): self.assertEqual(user.photos_count, 0) dt = datetime.strptime("1970-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ) self.assertEqual(user.photos_first_date, dt) self.assertEqual(user.photos_first_date_taken, dt) diff --git a/tests/flickr/test_fetch_filesfetchers.py b/tests/flickr/test_fetch_filesfetchers.py index 2cf7eae..39b1862 100644 --- a/tests/flickr/test_fetch_filesfetchers.py +++ b/tests/flickr/test_fetch_filesfetchers.py @@ -20,7 +20,7 @@ def setUp(self): self.photo_1 = PhotoFactory(title="p1", original_file="p1.jpg", user=user) the_time = datetime.strptime("2015-08-14", "%Y-%m-%d").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ) # Needs a taken_time for testing file save path: diff --git a/tests/flickr/test_fetch_savers.py b/tests/flickr/test_fetch_savers.py index 921c7ec..a133c61 100644 --- a/tests/flickr/test_fetch_savers.py +++ b/tests/flickr/test_fetch_savers.py @@ -22,7 +22,7 @@ def make_user_object(self, user_data): """ "Creates/updates a User from API data, then fetches that User from the DB and returns it. """ - fetch_time = datetime.now(tz=timezone.UTC) + fetch_time = datetime.now(tz=timezone.utc) UserSaver().save_user(user_data, fetch_time) return User.objects.get(nsid="35034346050@N01") @@ -33,7 +33,7 @@ def test_saves_correct_user_data(self): user_data = self.load_fixture("people.getInfo") user = self.make_user_object(user_data["person"]) - self.assertEqual(user.fetch_time, datetime.now(tz=timezone.UTC)) + self.assertEqual(user.fetch_time, datetime.now(tz=timezone.utc)) self.assertEqual(user.raw, json.dumps(user_data["person"])) self.assertEqual(user.nsid, "35034346050@N01") self.assertTrue(user.is_pro) @@ -48,12 +48,12 @@ def test_saves_correct_user_data(self): self.assertEqual(user.photos_count, 2876) self.assertEqual( user.photos_first_date, - datetime.fromtimestamp(1093459273, tz=timezone.UTC), + datetime.fromtimestamp(1093459273, tz=timezone.utc), ) self.assertEqual( user.photos_first_date_taken, datetime.strptime("1956-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual(user.photos_views, 227227) @@ -97,7 +97,7 @@ def make_photo_object(self, photo_data): def make_photo_data(self): """Makes the dict of data that photo_save() expects, based on API data.""" return { - "fetch_time": datetime.now(tz=timezone.UTC), + "fetch_time": datetime.now(tz=timezone.utc), "user_obj": UserFactory(nsid="35034346050@N01"), "info": self.load_fixture("photos.getInfo")["photo"], "exif": self.load_fixture("photos.getExif")["photo"], @@ -119,11 +119,11 @@ def test_saves_correct_photo_data(self, save_tags): ) self.assertFalse(photo.is_private) self.assertEqual(photo.summary, "Some test HTML. And another paragraph.") - self.assertEqual(photo.fetch_time, datetime.now(tz=timezone.UTC)) + self.assertEqual(photo.fetch_time, datetime.now(tz=timezone.utc)) self.assertEqual( photo.post_time, datetime.strptime("2016-03-28 16:05:05", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual(photo.latitude, Decimal("51.967930000")) @@ -148,7 +148,7 @@ def test_saves_correct_photo_data(self, save_tags): self.assertEqual( photo.last_update_time, datetime.strptime("2016-04-04 13:21:11", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual( @@ -375,7 +375,7 @@ def make_photoset_object(self, photoset_data): def make_photoset_data(self): """Makes the dict of data that photo_save() expects, based on API data.""" return { - "fetch_time": datetime.now(tz=timezone.UTC), + "fetch_time": datetime.now(tz=timezone.utc), "user_obj": UserFactory(nsid="35034346050@N01"), "photoset": self.load_fixture("photosets.getList")["photosets"]["photoset"][ 0 @@ -388,7 +388,7 @@ def test_saves_correct_photoset_data(self): photoset_data = self.make_photoset_data() photoset = self.make_photoset_object(photoset_data) - self.assertEqual(photoset.fetch_time, datetime.now(tz=timezone.UTC)) + self.assertEqual(photoset.fetch_time, datetime.now(tz=timezone.utc)) self.assertEqual(photoset.user, photoset_data["user_obj"]) self.assertEqual(photoset.flickr_id, 72157665648859705) @@ -403,13 +403,13 @@ def test_saves_correct_photoset_data(self): self.assertEqual( photoset.last_update_time, datetime.strptime("2016-03-28 16:02:03", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual( photoset.flickr_created_time, datetime.strptime("2016-03-08 19:37:04", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual(photoset.raw, json.dumps(photoset_data["photoset"])) diff --git a/tests/flickr/test_models.py b/tests/flickr/test_models.py index e86d473..3733177 100644 --- a/tests/flickr/test_models.py +++ b/tests/flickr/test_models.py @@ -136,13 +136,13 @@ def test_photoset_ordering(self): title="Earliest", flickr_created_time=datetime.strptime( "2016-04-07 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) PhotosetFactory( title="Latest", flickr_created_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) photosets = Photoset.objects.all() self.assertEqual(photosets[0].title, "Latest") @@ -214,13 +214,13 @@ def test_ordering(self): title="Earliest", post_time=datetime.strptime( "2016-04-07 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) PhotoFactory( title="Latest", post_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) photos = Photo.objects.all() self.assertEqual(photos[0].title, "Latest") @@ -473,7 +473,7 @@ def setUp(self): user=UserFactory(nsid="123456@N01"), taken_time=datetime.strptime( "2015-08-14 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) def tearDown(self): @@ -556,14 +556,14 @@ def setUp(self): user=user, post_time=datetime.strptime( "2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) self.private_photo = PhotoFactory( user=user, is_private=True, post_time=datetime.strptime( "2016-04-09 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) # Photo by a different user: user_2 = UserFactory() @@ -572,13 +572,13 @@ def setUp(self): user=user_2, post_time=datetime.strptime( "2016-04-10 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) self.photo_2 = PhotoFactory( user=user, post_time=datetime.strptime( "2016-04-11 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC), + ).replace(tzinfo=timezone.utc), ) def test_next_public_by_post_time(self): diff --git a/tests/flickr/test_templatetags.py b/tests/flickr/test_templatetags.py index 4717789..d70d494 100644 --- a/tests/flickr/test_templatetags.py +++ b/tests/flickr/test_templatetags.py @@ -73,8 +73,8 @@ def setUp(self): self.photos_1 = PhotoFactory.create_batch(2, user=user_1) self.photos_2 = PhotoFactory.create_batch(3, user=user_2) - taken_time = datetime(2014, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) + taken_time = datetime(2014, 3, 18, 12, 0, 0, tzinfo=timezone.utc) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.utc) self.photos_1[0].taken_time = taken_time self.photos_1[0].post_time = post_time self.photos_1[0].save() diff --git a/tests/lastfm/test_fetch.py b/tests/lastfm/test_fetch.py index 75030ce..bb17b50 100644 --- a/tests/lastfm/test_fetch.py +++ b/tests/lastfm/test_fetch.py @@ -214,7 +214,7 @@ def test_sends_from_time_correctly_for_recent(self): ScrobbleFactory( post_time=datetime.strptime( "2015-08-11 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC) + ).replace(tzinfo=timezone.utc) ) # Timestamp for 2015-08-11 12:00:00 UTC: self.add_recent_tracks_response(from_time=1439294400) @@ -389,7 +389,7 @@ def test_updates_existing_scrobbles(self): track = TrackFactory(artist=artist, slug="make+up") post_time = datetime.strptime( "2016-09-22 09:23:33", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC) + ).replace(tzinfo=timezone.utc) scrobble = ScrobbleFactory( account=self.account, track=track, post_time=post_time ) @@ -417,7 +417,7 @@ def test_sets_scrobble_data(self): self.assertEqual( scrobble.post_time, datetime.strptime("2016-09-22 09:23:33", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) json_data = self.load_fixture("user_getrecenttracks") diff --git a/tests/pinboard/test_fetch.py b/tests/pinboard/test_fetch.py index c0fba6c..b18210e 100644 --- a/tests/pinboard/test_fetch.py +++ b/tests/pinboard/test_fetch.py @@ -275,7 +275,7 @@ def test_fetch_json_parsing(self): self.assertEqual( bookmarks_data[0]["time"], datetime.strptime("2015-06-18T09:48:31Z", "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) @@ -303,7 +303,7 @@ def test_save_bookmarks(self): Bookmark objects. """ account = Account.objects.get(pk=1) - fetch_time = datetime.now(tz=timezone.UTC) + fetch_time = datetime.now(tz=timezone.utc) bookmarks_from_json = self.get_bookmarks_from_json() bookmarks_data = bookmarks_from_json["bookmarks"] @@ -325,7 +325,7 @@ def test_save_bookmarks(self): self.assertEqual( bookmarks[1].fetch_time, datetime.strptime("2015-07-01 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual( @@ -341,7 +341,7 @@ def test_save_bookmarks(self): self.assertEqual( bookmarks[1].post_time, datetime.strptime("2015-06-18T09:48:31Z", "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual( @@ -367,7 +367,7 @@ def test_update_bookmarks(self): """Ensure that when saving a Bookmark that already exists, we update it.""" account = Account.objects.get(pk=1) - fetch_time = datetime.now(tz=timezone.UTC) + fetch_time = datetime.now(tz=timezone.utc) # Add a Bookmark into the DB before we fetch anything. bookmark = BookmarkFactory( @@ -405,7 +405,7 @@ def test_update_bookmarks(self): self.assertEqual( bookmarks[1].fetch_time, datetime.strptime("2015-07-01 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) @@ -417,7 +417,7 @@ def test_update_bookmarks(self): def test_no_update_bookmarks(self): """Ensure that if no values have changed, we don't update a bookmark.""" account = Account.objects.get(pk=1) - fetch_time = datetime.now(tz=timezone.UTC) + fetch_time = datetime.now(tz=timezone.utc) # Add a Bookmark into the DB before we fetch anything. BookmarkFactory( diff --git a/tests/pinboard/test_models.py b/tests/pinboard/test_models.py index fe6837b..cf401f1 100644 --- a/tests/pinboard/test_models.py +++ b/tests/pinboard/test_models.py @@ -107,7 +107,7 @@ def test_ordering(self): account = AccountFactory(username="billy") post_time = datetime.strptime( "2015-01-01 12:00:00", "%Y-%m-%d %H:%M:%S" - ).replace(tzinfo=timezone.UTC) + ).replace(tzinfo=timezone.utc) bookmark_1 = BookmarkFactory(account=account, post_time=post_time) bookmark_2 = BookmarkFactory( account=account, post_time=(post_time + timedelta(days=1)) @@ -195,7 +195,7 @@ def test_post_year(self): class BookmarkNextPrevTestCase(TestCase): def setUp(self): dt = datetime.strptime("2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ) account = AccountFactory() diff --git a/tests/pinboard/test_templatetags.py b/tests/pinboard/test_templatetags.py index 92d1584..c927e99 100644 --- a/tests/pinboard/test_templatetags.py +++ b/tests/pinboard/test_templatetags.py @@ -42,7 +42,7 @@ def setUp(self): self.bookmarks_1 = BookmarkFactory.create_batch(6, account=account_1) self.bookmarks_2 = BookmarkFactory.create_batch(6, account=account_2) - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.utc) self.bookmarks_1[3].post_time = post_time self.bookmarks_1[3].save() self.bookmarks_1[5].is_private = True diff --git a/tests/twitter/test_fetch_savers.py b/tests/twitter/test_fetch_savers.py index e32fd20..97a7acb 100644 --- a/tests/twitter/test_fetch_savers.py +++ b/tests/twitter/test_fetch_savers.py @@ -69,7 +69,7 @@ def test_saves_correct_tweet_data(self): self.assertEqual( tweet.post_time, datetime.strptime("2015-08-06 19:42:59", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual(tweet.favorite_count, 2) @@ -420,7 +420,7 @@ def test_saves_correct_user_data(self): self.assertEqual( user.created_at, datetime.strptime("2006-11-15 16:55:59", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ), ) self.assertEqual(user.description, "Good. Good to Firm in places.") diff --git a/tests/twitter/test_models.py b/tests/twitter/test_models.py index 5666bc1..04b0a49 100644 --- a/tests/twitter/test_models.py +++ b/tests/twitter/test_models.py @@ -192,7 +192,7 @@ def test_media_type(self): def test_ordering(self): """Multiple accounts are sorted by time_created ascending""" - time_now = datetime.now(tz=timezone.UTC) + time_now = datetime.now(tz=timezone.utc) photo_1 = PhotoFactory(time_created=time_now - timedelta(minutes=1)) PhotoFactory(time_created=time_now) photos = Media.objects.all() @@ -411,7 +411,7 @@ def test_get_absolute_url(self): def test_ordering(self): """Multiple tweets are sorted by post_time descending""" - time_now = datetime.now(tz=timezone.UTC) + time_now = datetime.now(tz=timezone.utc) TweetFactory(post_time=time_now - timedelta(minutes=1)) tweet_2 = TweetFactory(post_time=time_now) tweets = Tweet.objects.all() @@ -626,7 +626,7 @@ def test_post_year(self): class TweetNextPrevTestCase(TestCase): def setUp(self): dt = datetime.strptime("2016-04-08 12:00:00", "%Y-%m-%d %H:%M:%S").replace( - tzinfo=timezone.UTC + tzinfo=timezone.utc ) user = UserFactory() diff --git a/tests/twitter/test_templatetags.py b/tests/twitter/test_templatetags.py index bfb4797..b39dbae 100644 --- a/tests/twitter/test_templatetags.py +++ b/tests/twitter/test_templatetags.py @@ -96,7 +96,7 @@ def setUp(self): self.tweets_2 = TweetFactory.create_batch(2, user=user_2) self.tweets_3 = TweetFactory.create_batch(2, user=user_3) - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.utc) self.tweets_1[0].post_time = post_time self.tweets_1[0].save() self.tweets_2[1].post_time = post_time + timedelta(hours=1) @@ -137,7 +137,7 @@ def setUp(self): self.tweets[0].user.is_private = True self.tweets[0].user.save() - post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.UTC) + post_time = datetime(2015, 3, 18, 12, 0, 0, tzinfo=timezone.utc) self.tweets[0].post_time = post_time self.tweets[0].save() self.tweets[1].post_time = post_time + timedelta(hours=1) From a4350656481211f552cc80d1eec936bec0a468ed Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:30:00 +0000 Subject: [PATCH 13/20] Set ruff target version --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3b6f514..762d3fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,3 @@ -[project] -requires-python = ">=3.9" [tool.coverage.report] omit = ["*/migrations/*"] @@ -38,6 +36,7 @@ select = [ "RUF100", # unused-noqa "RUF200", # invalid-pyproject-toml ] +target-version = ">=3.8" [tool.ruff.lint] ignore = [] From 964b5a1675df947fab2964bec139d8f85194164b Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Mon, 11 Dec 2023 19:30:47 +0000 Subject: [PATCH 14/20] Correctly set Ruff target version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 762d3fb..001e0b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ select = [ "RUF100", # unused-noqa "RUF200", # invalid-pyproject-toml ] -target-version = ">=3.8" +target-version = ">=3.9" [tool.ruff.lint] ignore = [] From f1200197672b382f359ea69dccce786ac8f60c2b Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Tue, 12 Dec 2023 10:53:30 +0000 Subject: [PATCH 15/20] Correct target-version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 001e0b7..4ad40cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ select = [ "RUF100", # unused-noqa "RUF200", # invalid-pyproject-toml ] -target-version = ">=3.9" +target-version = "3.9" [tool.ruff.lint] ignore = [] From 29151d302cd4543ad54a5be6d092785be5251d71 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Tue, 12 Dec 2023 10:54:54 +0000 Subject: [PATCH 16/20] Actually correct target-version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4ad40cb..64e07d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ select = [ "RUF100", # unused-noqa "RUF200", # invalid-pyproject-toml ] -target-version = "3.9" +target-version = "py39" [tool.ruff.lint] ignore = [] From 6f821b2d9e02349cd5eb690c87ad9bc93e8f6332 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Tue, 12 Dec 2023 11:01:49 +0000 Subject: [PATCH 17/20] Fix upload_to methods' positions --- ditto/flickr/models.py | 40 +++++++++++++++++++++------------------- ditto/twitter/models.py | 30 ++++++++++++++++-------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/ditto/flickr/models.py b/ditto/flickr/models.py index c59513f..8139d87 100644 --- a/ditto/flickr/models.py +++ b/ditto/flickr/models.py @@ -1,3 +1,5 @@ +# Because at this stage, don't want to move the upload_to methods around: +# ruff: noqa: DJ012 from django.db import models from django.templatetags.static import static from django.urls import reverse @@ -856,8 +858,26 @@ class User(TimeStampedModelMixin, DiffModelMixin, models.Model): null=False, blank=False, max_length=50, help_text="eg, 'Europe/London'." ) + def avatar_upload_path(self, filename): + "Make path under MEDIA_ROOT where avatar file will be saved." + # If NSID is '35034346050@N01', get '35034346050' + nsid = self.nsid[: self.nsid.index("@")] + + # If NSID is '35034346050@N01' + # then, 'flickr/60/50/35034346050/avatars/avatar_name.jpg' + return "/".join( + [ + app_settings.FLICKR_DIR_BASE, + nsid[-4:-2], + nsid[-2:], + self.nsid.replace("@", ""), + "avatars", + filename, + ] + ) + avatar = models.ImageField( - upload_to="avatar_upload_path", null=False, blank=True, default="" + upload_to=avatar_upload_path, null=False, blank=True, default="" ) objects = models.Manager() @@ -899,21 +919,3 @@ def original_icon_url(self): ) else: return "https://www.flickr.com/images/buddyicon.gif" - - def avatar_upload_path(self, filename): - "Make path under MEDIA_ROOT where avatar file will be saved." - # If NSID is '35034346050@N01', get '35034346050' - nsid = self.nsid[: self.nsid.index("@")] - - # If NSID is '35034346050@N01' - # then, 'flickr/60/50/35034346050/avatars/avatar_name.jpg' - return "/".join( - [ - app_settings.FLICKR_DIR_BASE, - nsid[-4:-2], - nsid[-2:], - self.nsid.replace("@", ""), - "avatars", - filename, - ] - ) diff --git a/ditto/twitter/models.py b/ditto/twitter/models.py index a8bc5de..74c1143 100644 --- a/ditto/twitter/models.py +++ b/ditto/twitter/models.py @@ -1,3 +1,5 @@ +# Because at this stage, don't want to move the upload_to methods around: +# ruff: noqa: DJ012 import contextlib import json import logging @@ -209,11 +211,23 @@ class Media(TimeStampedModelMixin, models.Model): ) # END VIDEO-ONLY PROPERTIES + def upload_path(self, filename): + "Make path under MEDIA_ROOT where original files will be saved." + + # eg get '12345678' from '12345678.jpg': + name = os.path.splitext(filename)[0] + + # If filename is '12345678.jpg': + # 'twitter/media/56/78/12345678.jpg' + return "/".join( + [app_settings.TWITTER_DIR_BASE, "media", name[-4:-2], name[-2:], filename] + ) + image_file = models.ImageField( - upload_to="upload_path", null=False, blank=True, default="" + upload_to=upload_path, null=False, blank=True, default="" ) mp4_file = models.FileField( - upload_to="upload_path", + upload_to=upload_path, null=False, blank=True, default="", @@ -318,18 +332,6 @@ def video_mime_type(self): return mime_type - def upload_path(self, filename): - "Make path under MEDIA_ROOT where original files will be saved." - - # eg get '12345678' from '12345678.jpg': - name = os.path.splitext(filename)[0] - - # If filename is '12345678.jpg': - # 'twitter/media/56/78/12345678.jpg' - return "/".join( - [app_settings.TWITTER_DIR_BASE, "media", name[-4:-2], name[-2:], filename] - ) - def _image_url(self, size): """ Helper for the self.*_url() property methods. From e5dd68a39cc81953f1956a1d790aff02dfc7e6a4 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Tue, 12 Dec 2023 11:03:39 +0000 Subject: [PATCH 18/20] Fix another upload_to method's position --- ditto/twitter/models.py | 58 ++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/ditto/twitter/models.py b/ditto/twitter/models.py index 74c1143..799cf6a 100644 --- a/ditto/twitter/models.py +++ b/ditto/twitter/models.py @@ -765,35 +765,6 @@ class User(TimeStampedModelMixin, DiffModelMixin, models.Model): null=False, blank=True, help_text="eg, the raw JSON from the API." ) - avatar = models.ImageField( - upload_to="avatar_upload_path", null=False, blank=True, default="" - ) - - favorites = models.ManyToManyField(Tweet, related_name="favoriting_users") - - objects = models.Manager() - # All Users that have Accounts: - objects_with_accounts = managers.WithAccountsManager() - - class Meta: - ordering = ["screen_name"] - - def __str__(self): - return "@%s" % self.screen_name - - def save(self, *args, **kwargs): - """If the user's privacy status has changed, we need to change the - privacy of all their tweets - And we also HTMLify their description. - """ - if self.get_field_diff("is_private") is not None: - Tweet.objects.filter(user=self).update(is_private=self.is_private) - self.make_description_html() - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse("twitter:user_detail", kwargs={"screen_name": self.screen_name}) - def avatar_upload_path(self, filename): """ Make path under MEDIA_ROOT where avatar file will be saved. @@ -822,6 +793,35 @@ def avatar_upload_path(self, filename): else: return "/".join([start, end]) + avatar = models.ImageField( + upload_to=avatar_upload_path, null=False, blank=True, default="" + ) + + favorites = models.ManyToManyField(Tweet, related_name="favoriting_users") + + objects = models.Manager() + # All Users that have Accounts: + objects_with_accounts = managers.WithAccountsManager() + + class Meta: + ordering = ["screen_name"] + + def __str__(self): + return "@%s" % self.screen_name + + def save(self, *args, **kwargs): + """If the user's privacy status has changed, we need to change the + privacy of all their tweets + And we also HTMLify their description. + """ + if self.get_field_diff("is_private") is not None: + Tweet.objects.filter(user=self).update(is_private=self.is_private) + self.make_description_html() + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("twitter:user_detail", kwargs={"screen_name": self.screen_name}) + def make_description_html(self): """Uses the raw JSON for the user to set self.description_html to a nice HTML version of the description. From 21788c7b4417d696f5d7bbf0c0bf7cbdf72e1700 Mon Sep 17 00:00:00 2001 From: Phil Gyford Date: Tue, 12 Dec 2023 12:35:15 +0000 Subject: [PATCH 19/20] Fix lots and lots of tests Mostly as a result of me doing stuff that Ruff said, but doing it wrongly. A few instances of datetimes, now with timezones, that were out, but which I *think* are now correct. And a few instances where a templatetag was being called with an arg instead of a kwarg --- ditto/core/templatetags/ditto_core.py | 3 ++- ditto/core/utils/downloader.py | 2 +- ditto/core/views.py | 2 +- ditto/flickr/fetch/fetchers.py | 6 +++--- ditto/flickr/fetch/filesfetchers.py | 10 +++++---- .../commands/fetch_flickr_originals.py | 2 +- .../flickr/templates/flickr/photo_detail.html | 4 ++-- ditto/lastfm/fetch.py | 2 +- ditto/lastfm/templatetags/ditto_lastfm.py | 4 ++-- ditto/pinboard/fetch.py | 2 +- .../templates/pinboard/includes/bookmark.html | 4 ++-- ditto/twitter/fetch/fetch.py | 8 +++---- ditto/twitter/ingest.py | 3 ++- ditto/twitter/models.py | 4 ++-- .../templates/twitter/includes/tweet.html | 4 ++-- tests/flickr/test_fetch_fetchers.py | 2 +- tests/pinboard/test_fetch.py | 21 ++++++++++++------- tests/twitter/test_ingest_v1.py | 6 +++--- tests/twitter/test_models.py | 2 +- 19 files changed, 50 insertions(+), 41 deletions(-) diff --git a/ditto/core/templatetags/ditto_core.py b/ditto/core/templatetags/ditto_core.py index 5dcb49d..7e9b26c 100644 --- a/ditto/core/templatetags/ditto_core.py +++ b/ditto/core/templatetags/ditto_core.py @@ -2,6 +2,7 @@ from django.http import QueryDict from django.urls import reverse from django.utils.html import format_html +from django.utils.safestring import mark_safe from ditto.core import app_settings from ditto.core.apps import ditto_apps @@ -142,7 +143,7 @@ def display_time(dt, *, link_to_day=False, granularity=0, case=None): elif case == "capfirst": visible_time = visible_time[0].upper() + visible_time[1:] - return format_html('', stamp, visible_time) + return format_html('', stamp, mark_safe(visible_time)) @register.simple_tag(takes_context=True) diff --git a/ditto/core/utils/downloader.py b/ditto/core/utils/downloader.py index ee168e2..7f6eba3 100644 --- a/ditto/core/utils/downloader.py +++ b/ditto/core/utils/downloader.py @@ -90,7 +90,7 @@ def make_filename(self, url, headers=None): # Could be like 'attachment; filename=26897200312.avi' headers["Content-Disposition"] m = re.search(r"filename\=(.*?)$", headers["Content-Disposition"]) - with contextlib.suppess(AttributeError, IndexError): + with contextlib.suppress(AttributeError, IndexError): filename = m.group(1) except KeyError: pass diff --git a/ditto/core/views.py b/ditto/core/views.py index 5841cf2..3b5de04 100644 --- a/ditto/core/views.py +++ b/ditto/core/views.py @@ -628,7 +628,7 @@ def _date_from_string( try: return ( datetime.datetime.strptime(force_str(datestr), format) - .astimezone(datetime.UTC) + .astimezone(datetime.timezone.utc) .date() ) except ValueError as err: diff --git a/ditto/flickr/fetch/fetchers.py b/ditto/flickr/fetch/fetchers.py index 204ddb3..8078849 100644 --- a/ditto/flickr/fetch/fetchers.py +++ b/ditto/flickr/fetch/fetchers.py @@ -385,7 +385,7 @@ def __init__(self, account): # Maximum date of photos to return, if days or start are passed in: # By default, set it before Flickr so we get everything. self.min_date = datetime.datetime.strptime("2000-01-01", "%Y-%m-%d").astimezone( - datetime.UTC + datetime.timezone.utc ) # Maximum date of photos to return, if end is passed in: @@ -415,12 +415,12 @@ def fetch(self, days=None, start=None, end=None): if start: self.min_date = datetime.datetime.strptime( f"{start} 00:00:00", "%Y-%m-%d %H:%M:%S" - ).astimezone(datetime.UTC) + ).astimezone(datetime.timezone.utc) if end: self.max_date = datetime.datetime.strptime( f"{end} 23:59:59", "%Y-%m-%d %H:%M:%S" - ).astimezone(datetime.UTC) + ).astimezone(datetime.timezone.utc) if (start and end) and (start > end): msg = "Start date must be before the end date." diff --git a/ditto/flickr/fetch/filesfetchers.py b/ditto/flickr/fetch/filesfetchers.py index ba800de..9176ecf 100644 --- a/ditto/flickr/fetch/filesfetchers.py +++ b/ditto/flickr/fetch/filesfetchers.py @@ -141,7 +141,9 @@ def _fetch_and_save_file(self, photo, media_type): with open(filepath, "rb") as reopened_file: django_file = File(reopened_file) - if media_type == "video": - photo.video_original_file.save(os.path.basename(filepath), django_file) - else: - photo.original_file.save(os.path.basename(filepath), django_file) + if media_type == "video": + photo.video_original_file.save( + os.path.basename(filepath), django_file + ) + else: + photo.original_file.save(os.path.basename(filepath), django_file) diff --git a/ditto/flickr/management/commands/fetch_flickr_originals.py b/ditto/flickr/management/commands/fetch_flickr_originals.py index f628abd..a019b35 100644 --- a/ditto/flickr/management/commands/fetch_flickr_originals.py +++ b/ditto/flickr/management/commands/fetch_flickr_originals.py @@ -37,7 +37,7 @@ def handle(self, *args, **options): # We might be fetching for a specific account or all (None). nsid = options["account"] if options["account"] else None - results = self.fetch_files(nsid, options["all"]) + results = self.fetch_files(nsid, fetch_all=options["all"]) self.output_results(results, options.get("verbosity", 1)) def fetch_files(self, nsid, *, fetch_all=False): diff --git a/ditto/flickr/templates/flickr/photo_detail.html b/ditto/flickr/templates/flickr/photo_detail.html index 2bbca4b..6f7d6ad 100644 --- a/ditto/flickr/templates/flickr/photo_detail.html +++ b/ditto/flickr/templates/flickr/photo_detail.html @@ -206,10 +206,10 @@

Albums

  • - Last updated on Flickr at {% display_time photo.last_update_time True %} + Last updated on Flickr at {% display_time photo.last_update_time link_to_day=True %}
  • - As of {% display_time photo.fetch_time True %} + As of {% display_time photo.fetch_time link_to_day=True %}
diff --git a/ditto/lastfm/fetch.py b/ditto/lastfm/fetch.py index 70cf92a..526062a 100644 --- a/ditto/lastfm/fetch.py +++ b/ditto/lastfm/fetch.py @@ -189,7 +189,7 @@ def _send_request(self): ) response.raise_for_status() # Raises an exception on HTTP error. except requests.exceptions.RequestException as err: - msg = "Error when fetching Scrobbles (page {self.page_number}): {err}" + msg = f"Error when fetching Scrobbles (page {self.page_number}): {err}" raise FetchError(msg) from err response.encoding = "utf-8" diff --git a/ditto/lastfm/templatetags/ditto_lastfm.py b/ditto/lastfm/templatetags/ditto_lastfm.py index 1b13f11..8090b57 100644 --- a/ditto/lastfm/templatetags/ditto_lastfm.py +++ b/ditto/lastfm/templatetags/ditto_lastfm.py @@ -61,10 +61,10 @@ def get_period_times(date, period): # `date` is a datetime.date min_time = datetime.datetime.combine( date, datetime.datetime.min.time() - ).replace(tzinfo=datetime.UTC) + ).replace(tzinfo=datetime.timezone.utc) max_time = datetime.datetime.combine( date, datetime.datetime.max.time() - ).replace(tzinfo=datetime.UTC) + ).replace(tzinfo=datetime.timezone.utc) if period == "week": # Default is Sunday (0): diff --git a/ditto/pinboard/fetch.py b/ditto/pinboard/fetch.py index 1c1027b..dce9d16 100644 --- a/ditto/pinboard/fetch.py +++ b/ditto/pinboard/fetch.py @@ -249,7 +249,7 @@ def fetch(self, post_date, username=None): FetchError if the date format is invalid. """ try: - dt = datetime.strptime(post_date, "%Y-%m-%d").astimezone(timezone.UTC) + dt = datetime.strptime(post_date, "%Y-%m-%d").astimezone(timezone.utc) except ValueError as err: raise FetchError("Invalid date format ('%s')" % post_date) from err else: diff --git a/ditto/pinboard/templates/pinboard/includes/bookmark.html b/ditto/pinboard/templates/pinboard/includes/bookmark.html index 856a529..9462076 100644 --- a/ditto/pinboard/templates/pinboard/includes/bookmark.html +++ b/ditto/pinboard/templates/pinboard/includes/bookmark.html @@ -40,9 +40,9 @@