Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions procrastinate/contrib/django/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@

from django.contrib import admin
from django.db.models import Prefetch
from django.http import JsonResponse
from django.template.loader import render_to_string
from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe

from procrastinate.utils import ellipsize_middle

from . import models

JOB_STATUS_EMOJI_MAPPING = {
Expand Down Expand Up @@ -90,6 +94,17 @@ def get_queryset(self, request):
)
)

def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"<int:job_id>/full_args/",
self.admin_site.admin_view(self.full_args_view),
name="full_args",
),
]
return custom_urls + urls

@admin.display(description="Status")
def pretty_status(self, instance: models.ProcrastinateJob) -> str:
emoji = JOB_STATUS_EMOJI_MAPPING.get(instance.status, "")
Expand All @@ -106,12 +121,19 @@ def short_task_name(self, instance: models.ProcrastinateJob) -> str:

@admin.display(description="Args")
def pretty_args(self, instance: models.ProcrastinateJob) -> str:
indent = 2 if len(instance.args) > 1 or len(str(instance.args)) > 30 else None
pretty_json = json.dumps(instance.args, indent=indent)
if len(pretty_json) > 2000:
pretty_json = pretty_json[:2000] + "..."
rows = format_html_join(
"\n",
"<tr><td>{}</td><td>{}</td></tr>",
(
(key, ellipsize_middle(json.dumps(value)))
for key, value in instance.args.items()
),
)
return format_html(
'<pre style="margin: 0">{pretty_json}</pre>', pretty_json=pretty_json
"<table>{rows}</table>"
'<div style="margin-top: 8px"><a href="{full_args_url}">View unformatted</a></div>',
rows=rows,
full_args_url=reverse("admin:full_args", kwargs={"job_id": instance.id}),
)

@admin.display(description="Summary")
Expand All @@ -128,3 +150,7 @@ def summary(self, instance: models.ProcrastinateJob) -> str:
).strip()
)
return ""

def full_args_view(self, request, job_id):
instance = models.ProcrastinateJob.objects.get(id=job_id)
return JsonResponse(instance.args)
4 changes: 3 additions & 1 deletion procrastinate/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing_extensions import Literal

from procrastinate import types
from procrastinate.utils import ellipsize_middle

if TYPE_CHECKING:
from procrastinate import manager
Expand Down Expand Up @@ -136,7 +137,8 @@ def evolve(self, **kwargs: Any) -> Job:
@cached_property
def call_string(self):
kwargs_string = ", ".join(
f"{key}={value!r}" for key, value in self.task_kwargs.items()
f"{key}={ellipsize_middle(repr(value))}"
for key, value in self.task_kwargs.items()
Comment on lines +140 to +141
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you left out the part where we allow people to override this. It might be ok in this PR, but I'd like to make sure we're fine with this before a release. Doesn't have to be something gigantic, but this may be ruining someone's workflow with no hopes of improvement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I just saw your comment in the PR body ! I'll try to think about it. It's ok to leave it out of the PR for now if it's complicated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's easier if it can be configured in the tasks, then ?

Copy link
Contributor Author

@jakajancar jakajancar May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This actually made me think... Perhaps a better solution to all of this would be to have a way to simply exclude an argument from display (call_string, log context, and Django admin), for whatever reason (security, size).

This seems much simpler than all of this.

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, it seemed much simpler but:

  • We can define on Task.unlogged_args, but you can't get from Job -> Task easily
  • Seems wrong to persist fixed argument names into Job/database
  • Also seems fairly "dangerous": you forget to filter in one place (call string, log context, admin, shell) and you're printing what you should not be.
  • Perhaps splitting task_kwargs into task_kwargs+task_unlogged_kwargs but that seems like an overkill too.

)
return f"{self.task_name}[{self.id}]({kwargs_string})"

Expand Down
14 changes: 14 additions & 0 deletions procrastinate/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,17 @@ def queues_display(queues: Iterable[str] | None) -> str:
return f"queues {', '.join(queues)}"
else:
return "all queues"


def ellipsize_middle(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this function, and it seems like the perfect candidate for a parametrize set of unit tests :)

In particular, I like that this implementation works well around 100 chars (if we look at what happens char by char, there's no moment where we add 1 char and we end up with ellipsis and with the whole string being less than 100 chars). That said, I felt the need to test it to be 100% sure of that, so it would be awesome if the tests were included :D

I specifically liked a lot that all the important configuration is passed as parameters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I know, I'm also annoyed by the counterproductive ellipses in some implementations, hehe. Will add tests.

value: str, max_length: int = 100, suffix_length: int = 20, ellipsis: str = "..."
) -> str:
"""
Limits the length of a string to `max_length` by placing `ellipsis` in the middle, preserving `suffix_length` at the end.
"""
prefix_length = max_length - len(ellipsis) - suffix_length

if len(value) > max_length:
return value[:prefix_length] + ellipsis + value[-suffix_length:]
else:
return value
10 changes: 10 additions & 0 deletions tests/unit/test_jobs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import datetime
import json

import pytest

Expand Down Expand Up @@ -47,6 +48,15 @@ def test_job_get_context(job_factory, scheduled_at, context_scheduled_at):
}


def test_log_context_does_not_grow_infinitely(job_factory):
large_arg_len = 10**5
job = job_factory(
task_kwargs={"large_arg": "a" * large_arg_len},
)

assert len(json.dumps(job.log_context())) < large_arg_len


def test_job_evolve(job_factory):
job = job_factory(id=12, task_name="mytask", lock="sher", queue="marsupilami")
expected = job_factory(id=13, task_name="mytask", lock="bu", queue="marsupilami")
Expand Down
Loading