From d4edc6cd1678ef93d0b36a5ea318ea7556406f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 13:00:54 +0200 Subject: [PATCH 1/9] Note numerics in help for command --- serveradmin/graphite/management/commands/cache_graphite.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index ef070405..96f239f7 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -1,6 +1,6 @@ """Serveradmin - Graphite Integration -Copyright (c) 2022 InnoGames GmbH +Copyright (c) 2023 InnoGames GmbH """ import json @@ -33,7 +33,7 @@ class Command(BaseCommand): - """Generate sprites from the overview graphics""" + """Generate sprites and update numeric values for collections.""" help = __doc__ def handle(self, *args, **kwargs): From b03556d33729c78e1c065817dcdfaa6c8da5783a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 13:14:20 +0200 Subject: [PATCH 2/9] Allow to specify collections to update by name --- .../graphite/management/commands/cache_graphite.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index 96f239f7..ac3478d3 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -19,7 +19,7 @@ from PIL import Image from django.conf import settings -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from django.db import transaction from adminapi import filters @@ -36,7 +36,10 @@ class Command(BaseCommand): """Generate sprites and update numeric values for collections.""" help = __doc__ - def handle(self, *args, **kwargs): + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument("--collections", nargs='*', type=str, help='Generate/update only these collections.') + + def handle(self, *args, **options): """The entry point of the command""" start = time.time() @@ -47,7 +50,11 @@ def handle(self, *args, **kwargs): mkdir(sprite_dir) # We will make sure to generate a single sprite for a single hostname. - for collection in Collection.objects.filter(overview=True): + collections = Collection.objects.filter(overview=True) + if options['collections']: + collections = collections.filter(name__in=options['collections']) + + for collection in collections: collection_start = time.time() collection_dir = sprite_dir + '/' + collection.name From c11c8dc80b8866391c8eaf6badff03815282238d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 13:29:28 +0200 Subject: [PATCH 3/9] Allow to specify Serveradmin query to limit objects --- .../management/commands/cache_graphite.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index ac3478d3..d809c8e2 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -23,6 +23,7 @@ from django.db import transaction from adminapi import filters +from adminapi.parse import parse_query from serveradmin.dataset import Query from serveradmin.graphite.models import ( GRAPHITE_ATTRIBUTE_ID, @@ -38,6 +39,7 @@ class Command(BaseCommand): def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("--collections", nargs='*', type=str, help='Generate/update only these collections.') + parser.add_argument("--query", type=str, help="Generate/update only objects matching this Serveradmin query.") def handle(self, *args, **options): """The entry point of the command""" @@ -49,7 +51,6 @@ def handle(self, *args, **options): if not isdir(sprite_dir): mkdir(sprite_dir) - # We will make sure to generate a single sprite for a single hostname. collections = Collection.objects.filter(overview=True) if options['collections']: collections = collections.filter(name__in=options['collections']) @@ -61,11 +62,15 @@ def handle(self, *args, **options): if not isdir(collection_dir): mkdir(collection_dir) - for server in Query( - { - GRAPHITE_ATTRIBUTE_ID: collection.name, - 'state': filters.Not('retired'), - }): + query_filter = { + GRAPHITE_ATTRIBUTE_ID: collection.name, + "state": filters.Not("retired"), + } + + if options["query"]: + query_filter = query_filter.update(**parse_query(options["query"])) + + for server in Query(query_filter, ["hostname"]): graph_table = collection.graph_table(server, sprite_params) if graph_table: self.generate_sprite(collection_dir, graph_table, server) From 10612f92950ee7448a2b6004a8139cd4f273ff49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 13:50:13 +0200 Subject: [PATCH 4/9] Improve logging output --- .../management/commands/cache_graphite.py | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index d809c8e2..c77cf9ef 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -5,7 +5,6 @@ import json import time -from datetime import datetime from decimal import Decimal from io import BytesIO from os import mkdir @@ -21,6 +20,7 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandParser from django.db import transaction +from django.utils.timezone import now from adminapi import filters from adminapi.parse import parse_query @@ -44,8 +44,6 @@ def add_arguments(self, parser: CommandParser) -> None: def handle(self, *args, **options): """The entry point of the command""" - start = time.time() - sprite_params = settings.GRAPHITE_SPRITE_PARAMS sprite_dir = settings.MEDIA_ROOT + '/graph_sprite' if not isdir(sprite_dir): @@ -56,7 +54,7 @@ def handle(self, *args, **options): collections = collections.filter(name__in=options['collections']) for collection in collections: - collection_start = time.time() + self.stdout.write(f"[{now()}] Starting collection {collection}") collection_dir = sprite_dir + '/' + collection.name if not isdir(collection_dir): @@ -68,21 +66,17 @@ def handle(self, *args, **options): } if options["query"]: - query_filter = query_filter.update(**parse_query(options["query"])) + query_filter.update(**parse_query(options["query"])) for server in Query(query_filter, ["hostname"]): graph_table = collection.graph_table(server, sprite_params) if graph_table: self.generate_sprite(collection_dir, graph_table, server) + self.stdout.write(f"[{now()}] Generated sprite for {server['hostname']}") self.cache_numerics(collection, server) + self.stdout.write(f"[{now()}] Updated numerics for {server['hostname']}") - collection_duration = time.time() - collection_start - print('[{}] Collection {} finished after {} seconds'.format( - datetime.now(), collection.name, collection_duration)) - - duration = time.time() - start - print('[{}] Finished after {} seconds'.format(datetime.now(), - duration)) + self.stdout.write(f"[{now()}] Finished collection {collection}") def generate_sprite(self, collection_dir, graph_table, server): """Generate sprites for the given server using the given collection""" @@ -113,15 +107,11 @@ def cache_numerics(self, collection, server): try: value = response_json[0]['datapoints'][0][0] except IndexError: - print( - ( - "Warning: Graphite response '{}' for collection {}/{}" - " on server {} couldn't be parsed" - ).format(response, collection, numeric, server['hostname']) - ) + self.stdout.write(self.style.NOTICE(f"[{now()}] {server['hostname']}: Can't parse response {response} for {params}.")) continue if value is None: + self.stdout.write(self.style.NOTICE(f"[{now()}] {server['hostname']}: None value for {params} received.")) continue # Django can be set up to implicitly execute commands in database @@ -158,12 +148,8 @@ def get_from_graphite(self, params): with build_opener(auth_handler).open(url) as response: return response.read() except HTTPError as error: - print('Warning: Graphite returned ' + str(error) + ' to ' + url) + self.stdout.write(self.style.NOTICE(f"Graphite returned {error} for {url}")) finally: end = time.time() if end - start > 10: - print( - 'Warning: Graphite request to {0} took {1} seconds'.format( - url, end - start - ) - ) + self.stdout.write(self.style.WARNING(f"Graphite request {url} took {end - start} seconds")) From a968c8fc85e62f2ee8fd5964283a476fad0a9042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 14:05:57 +0200 Subject: [PATCH 5/9] Handle exception when server has been deleted --- .../graphite/management/commands/cache_graphite.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index c77cf9ef..eafbdc0d 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -119,10 +119,14 @@ def cache_numerics(self, collection, server): # it is set up like this. This process takes a long time. # We want the values to be immediately available to the users. with transaction.atomic(): - # Lock server for changes to avoid nonrepeatable reads in the - # query_committer. - locked_server = Server.objects.select_for_update().get( - server_id=server.object_id) + try: + # Lock server for changes to avoid non-repeatable reads in the + # query_committer. + locked_server = Server.objects.select_for_update().get(server_id=server.object_id) + except Server.DoesNotExist: + self.stdout.write(self.style.NOTICE(f"[{now()}] {server['hostname']} has been deleted.")) + continue + locked_server.servernumberattribute_set.update_or_create( server_id=locked_server.server_id, attribute=numeric.attribute, From 31c1689b3371c2e2b2584002496877509c350a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 14:12:44 +0200 Subject: [PATCH 6/9] Print total time when done --- serveradmin/graphite/management/commands/cache_graphite.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index eafbdc0d..181c07f4 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -44,6 +44,8 @@ def add_arguments(self, parser: CommandParser) -> None: def handle(self, *args, **options): """The entry point of the command""" + start = time.time() + sprite_params = settings.GRAPHITE_SPRITE_PARAMS sprite_dir = settings.MEDIA_ROOT + '/graph_sprite' if not isdir(sprite_dir): @@ -78,6 +80,9 @@ def handle(self, *args, **options): self.stdout.write(f"[{now()}] Finished collection {collection}") + end = time.time() + self.stdout.write(self.style.SUCCESS(f"Total time: {end - start:.2f} seconds.")) + def generate_sprite(self, collection_dir, graph_table, server): """Generate sprites for the given server using the given collection""" graphs = [v2 for k1, v1 in graph_table for k2, v2 in v1] From 8936f2cd587eb1da26a8e0fb34c96239d785f6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Fri, 21 Jul 2023 15:01:50 +0200 Subject: [PATCH 7/9] Run sprite/numeric generation in threads --- .../management/commands/cache_graphite.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index 181c07f4..bd0bffc8 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -5,6 +5,7 @@ import json import time +from concurrent.futures import ThreadPoolExecutor from decimal import Decimal from io import BytesIO from os import mkdir @@ -40,10 +41,15 @@ class Command(BaseCommand): def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("--collections", nargs='*', type=str, help='Generate/update only these collections.') parser.add_argument("--query", type=str, help="Generate/update only objects matching this Serveradmin query.") + parser.add_argument("--threads", type=int, default=5, help="Generate n sprites/numerics concurrently.") def handle(self, *args, **options): """The entry point of the command""" + if options["threads"] < 1: + self.stderr.write(self.style.ERROR(f"--threads must be greater 0!")) + exit(1) + start = time.time() sprite_params = settings.GRAPHITE_SPRITE_PARAMS @@ -70,21 +76,24 @@ def handle(self, *args, **options): if options["query"]: query_filter.update(**parse_query(options["query"])) - for server in Query(query_filter, ["hostname"]): - graph_table = collection.graph_table(server, sprite_params) - if graph_table: - self.generate_sprite(collection_dir, graph_table, server) - self.stdout.write(f"[{now()}] Generated sprite for {server['hostname']}") - self.cache_numerics(collection, server) - self.stdout.write(f"[{now()}] Updated numerics for {server['hostname']}") + futures = [] + with ThreadPoolExecutor(options["threads"]) as executor: + for server in Query(query_filter, ["hostname"]): + futures.append(executor.submit(self.generate_sprite, collection_dir, server, collection, sprite_params)) + futures.append(executor.submit(self.cache_numerics, collection, server)) self.stdout.write(f"[{now()}] Finished collection {collection}") end = time.time() self.stdout.write(self.style.SUCCESS(f"Total time: {end - start:.2f} seconds.")) - def generate_sprite(self, collection_dir, graph_table, server): + def generate_sprite(self, collection_dir, server, collection, sprite_params): """Generate sprites for the given server using the given collection""" + + graph_table = collection.graph_table(server, sprite_params) + if not graph_table: + return + graphs = [v2 for k1, v1 in graph_table for k2, v2 in v1] sprite_width = settings.GRAPHITE_SPRITE_WIDTH sprite_height = settings.GRAPHITE_SPRITE_HEIGHT @@ -99,6 +108,8 @@ def generate_sprite(self, collection_dir, graph_table, server): sprite_img.save(collection_dir + '/' + server['hostname'] + '.png') + self.stdout.write(f"[{now()}] Generated sprite for {server['hostname']}") + def cache_numerics(self, collection, server): """Generate sprites for the given server using the given collection""" for numeric in collection.numeric_set.all(): @@ -138,6 +149,8 @@ def cache_numerics(self, collection, server): defaults={'value': Decimal(value)}, ) + self.stdout.write(f"[{now()}] Updated numerics for {server['hostname']}") + def get_from_graphite(self, params): """Make a GET request to Graphite with the given params""" password_mgr = HTTPPasswordMgrWithDefaultRealm() From 8134c4df8635f172e944da676d0314d5fead03e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Tue, 25 Jul 2023 16:36:46 +0200 Subject: [PATCH 8/9] Log current time on all outputs --- serveradmin/graphite/management/commands/cache_graphite.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index bd0bffc8..58ee763e 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -85,7 +85,7 @@ def handle(self, *args, **options): self.stdout.write(f"[{now()}] Finished collection {collection}") end = time.time() - self.stdout.write(self.style.SUCCESS(f"Total time: {end - start:.2f} seconds.")) + self.stdout.write(self.style.SUCCESS(f"[{now()}] Total time: {end - start:.2f} seconds.")) def generate_sprite(self, collection_dir, server, collection, sprite_params): """Generate sprites for the given server using the given collection""" @@ -170,8 +170,8 @@ def get_from_graphite(self, params): with build_opener(auth_handler).open(url) as response: return response.read() except HTTPError as error: - self.stdout.write(self.style.NOTICE(f"Graphite returned {error} for {url}")) + self.stdout.write(self.style.NOTICE(f"[{now()}] Graphite returned {error} for {url}")) finally: end = time.time() if end - start > 10: - self.stdout.write(self.style.WARNING(f"Graphite request {url} took {end - start} seconds")) + self.stdout.write(self.style.WARNING(f"[{now()}] Graphite request {url} took {end - start} seconds")) From 215e8f562cf65960c036082c5d4d64cdd98a032b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kr=C3=B6ger?= Date: Tue, 25 Jul 2023 16:38:15 +0200 Subject: [PATCH 9/9] Reformat file to ensure common code style --- .../management/commands/cache_graphite.py | 99 ++++++++++++++----- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/serveradmin/graphite/management/commands/cache_graphite.py b/serveradmin/graphite/management/commands/cache_graphite.py index 58ee763e..9dc65659 100644 --- a/serveradmin/graphite/management/commands/cache_graphite.py +++ b/serveradmin/graphite/management/commands/cache_graphite.py @@ -14,34 +14,49 @@ from urllib.request import ( HTTPBasicAuthHandler, HTTPPasswordMgrWithDefaultRealm, - build_opener + build_opener, ) -from PIL import Image from django.conf import settings from django.core.management.base import BaseCommand, CommandParser from django.db import transaction from django.utils.timezone import now +from PIL import Image from adminapi import filters from adminapi.parse import parse_query from serveradmin.dataset import Query from serveradmin.graphite.models import ( GRAPHITE_ATTRIBUTE_ID, - Collection, AttributeFormatter, + Collection, ) from serveradmin.serverdb.models import Server class Command(BaseCommand): """Generate sprites and update numeric values for collections.""" + help = __doc__ def add_arguments(self, parser: CommandParser) -> None: - parser.add_argument("--collections", nargs='*', type=str, help='Generate/update only these collections.') - parser.add_argument("--query", type=str, help="Generate/update only objects matching this Serveradmin query.") - parser.add_argument("--threads", type=int, default=5, help="Generate n sprites/numerics concurrently.") + parser.add_argument( + "--collections", + nargs="*", + type=str, + help="Generate/update only these collections.", + ) + parser.add_argument( + "--query", + type=str, + help="Generate/update only objects matching this Serveradmin query.", + ) + parser.add_argument( + "--threads", + type=int, + default=5, + help="Generate n sprites/numerics concurrently.", + ) def handle(self, *args, **options): """The entry point of the command""" @@ -53,18 +68,18 @@ def handle(self, *args, **options): start = time.time() sprite_params = settings.GRAPHITE_SPRITE_PARAMS - sprite_dir = settings.MEDIA_ROOT + '/graph_sprite' + sprite_dir = settings.MEDIA_ROOT + "/graph_sprite" if not isdir(sprite_dir): mkdir(sprite_dir) collections = Collection.objects.filter(overview=True) - if options['collections']: - collections = collections.filter(name__in=options['collections']) + if options["collections"]: + collections = collections.filter(name__in=options["collections"]) for collection in collections: self.stdout.write(f"[{now()}] Starting collection {collection}") - collection_dir = sprite_dir + '/' + collection.name + collection_dir = sprite_dir + "/" + collection.name if not isdir(collection_dir): mkdir(collection_dir) @@ -79,13 +94,25 @@ def handle(self, *args, **options): futures = [] with ThreadPoolExecutor(options["threads"]) as executor: for server in Query(query_filter, ["hostname"]): - futures.append(executor.submit(self.generate_sprite, collection_dir, server, collection, sprite_params)) - futures.append(executor.submit(self.cache_numerics, collection, server)) + futures.append( + executor.submit( + self.generate_sprite, + collection_dir, + server, + collection, + sprite_params, + ) + ) + futures.append( + executor.submit(self.cache_numerics, collection, server) + ) self.stdout.write(f"[{now()}] Finished collection {collection}") end = time.time() - self.stdout.write(self.style.SUCCESS(f"[{now()}] Total time: {end - start:.2f} seconds.")) + self.stdout.write( + self.style.SUCCESS(f"[{now()}] Total time: {end - start:.2f} seconds.") + ) def generate_sprite(self, collection_dir, server, collection, sprite_params): """Generate sprites for the given server using the given collection""" @@ -98,7 +125,7 @@ def generate_sprite(self, collection_dir, server, collection, sprite_params): sprite_width = settings.GRAPHITE_SPRITE_WIDTH sprite_height = settings.GRAPHITE_SPRITE_HEIGHT total_width = len(graphs) * sprite_width - sprite_img = Image.new('RGB', (total_width, sprite_height), (255,) * 3) + sprite_img = Image.new("RGB", (total_width, sprite_height), (255,) * 3) for graph, offset in zip(graphs, range(0, total_width, sprite_width)): response = self.get_from_graphite(graph) @@ -106,7 +133,7 @@ def generate_sprite(self, collection_dir, server, collection, sprite_params): box = (offset, 0, offset + sprite_width, sprite_height) sprite_img.paste(Image.open(BytesIO(response)), box) - sprite_img.save(collection_dir + '/' + server['hostname'] + '.png') + sprite_img.save(collection_dir + "/" + server["hostname"] + ".png") self.stdout.write(f"[{now()}] Generated sprite for {server['hostname']}") @@ -119,15 +146,23 @@ def cache_numerics(self, collection, server): if not response: continue - response_json = json.loads(response.decode('utf8')) + response_json = json.loads(response.decode("utf8")) try: - value = response_json[0]['datapoints'][0][0] + value = response_json[0]["datapoints"][0][0] except IndexError: - self.stdout.write(self.style.NOTICE(f"[{now()}] {server['hostname']}: Can't parse response {response} for {params}.")) + self.stdout.write( + self.style.NOTICE( + f"[{now()}] {server['hostname']}: Can't parse response {response} for {params}." + ) + ) continue if value is None: - self.stdout.write(self.style.NOTICE(f"[{now()}] {server['hostname']}: None value for {params} received.")) + self.stdout.write( + self.style.NOTICE( + f"[{now()}] {server['hostname']}: None value for {params} received." + ) + ) continue # Django can be set up to implicitly execute commands in database @@ -138,15 +173,21 @@ def cache_numerics(self, collection, server): try: # Lock server for changes to avoid non-repeatable reads in the # query_committer. - locked_server = Server.objects.select_for_update().get(server_id=server.object_id) + locked_server = Server.objects.select_for_update().get( + server_id=server.object_id + ) except Server.DoesNotExist: - self.stdout.write(self.style.NOTICE(f"[{now()}] {server['hostname']} has been deleted.")) + self.stdout.write( + self.style.NOTICE( + f"[{now()}] {server['hostname']} has been deleted." + ) + ) continue locked_server.servernumberattribute_set.update_or_create( server_id=locked_server.server_id, attribute=numeric.attribute, - defaults={'value': Decimal(value)}, + defaults={"value": Decimal(value)}, ) self.stdout.write(f"[{now()}] Updated numerics for {server['hostname']}") @@ -161,17 +202,21 @@ def get_from_graphite(self, params): settings.GRAPHITE_PASSWORD, ) auth_handler = HTTPBasicAuthHandler(password_mgr) - url = '{0}/render?{1}'.format( - settings.GRAPHITE_URL, params - ) + url = "{0}/render?{1}".format(settings.GRAPHITE_URL, params) start = time.time() try: with build_opener(auth_handler).open(url) as response: return response.read() except HTTPError as error: - self.stdout.write(self.style.NOTICE(f"[{now()}] Graphite returned {error} for {url}")) + self.stdout.write( + self.style.NOTICE(f"[{now()}] Graphite returned {error} for {url}") + ) finally: end = time.time() if end - start > 10: - self.stdout.write(self.style.WARNING(f"[{now()}] Graphite request {url} took {end - start} seconds")) + self.stdout.write( + self.style.WARNING( + f"[{now()}] Graphite request {url} took {end - start} seconds" + ) + )