Reformat codebase to satisfy CI linter check #306

Closed
opened 2026-05-14 07:07:19 +00:00 by jgroman · 30 comments
Owner
  • Evaluate use of linters/formatters flake8+black vs ruff, pick solution: #311
  • Use formatter to reformat codebase: #312
  • Fix linter errors to pass ./run lint: #317
- [x] Evaluate use of linters/formatters flake8+black vs ruff, pick solution: #311 - [x] Use formatter to reformat codebase: #312 - [x] Fix linter errors to pass `./run lint`: #317
jgroman self-assigned this 2026-05-14 07:07:19 +00:00
jgroman added this to the Sprint 10 project 2026-05-14 07:07:19 +00:00
Owner

I'm interested to compare the options as well.

I'm all for linters and blocking on them (at least when they look for actual coding issues and high-profile warnings, and not cosmetic stuff). We could check ty, which intends to replace mypy.

I don't have much experience with formatters. The idea that you don't need to think about formatting and it does a great job automatically sounds great on paper. But when I sometimes see black output, I don't like it very much. It seem more fit for machine reading than human reading, it's breaks lines very often and creates very long source code. The fact that it is "opinionated" and doesn't allow almost any configuration options doesn't help much. I'd like to have the benefits without feeling abused. I'll play with it more, and compare the output.

There's a blue fork which makes a few changes, especially with quotes.

ruff seems to produce almost exactly the same output as black, but allows more configuration options. For example you can decide whether to use single or double quotes (I find those harder to read), or even preserve the existing style, without reformatting everything. So this might be a bit better, even though will still probably produce too long code. It also has its own integrated version of flake8 and isort, so you don't need those.

Another option seems to be YAPF, which seems to produce more human-readable code, and is very configurable.

Then there is autopep8, which doesn't change the overall layout of the code, just fix PEP 8 violations.

Let's make this a research ticket ("spike") to compare and discuss the options, wdyt? Then we can have a separate ticket for actually implementing the changes. I'd start with linting, that seems less controversial (OTOH, it kinda depends whether we go with all-in-one ruff, or a collection of other tools).

I'm interested to compare the options as well. I'm all for linters and blocking on them (at least when they look for actual coding issues and high-profile warnings, and not cosmetic stuff). We could check **ty**, which intends to replace **mypy**. I don't have much experience with formatters. The idea that you don't need to think about formatting and it does a great job automatically sounds great on paper. But when I sometimes see **black** output, I don't like it very much. It seem more fit for machine reading than human reading, it's breaks lines very often and creates very long source code. The fact that it is "opinionated" and doesn't allow almost any configuration options doesn't help much. I'd like to have the benefits without feeling abused. I'll play with it more, and compare the output. There's a **blue** fork which makes a few changes, especially with quotes. **ruff** seems to produce almost exactly the same output as **black**, but allows more configuration options. For example you can decide whether to use single or double quotes (I find those harder to read), or even preserve the existing style, without reformatting everything. So this might be a bit better, even though will still probably produce too long code. It also has its own integrated version of **flake8** and **isort**, so you don't need those. Another option seems to be **YAPF**, which seems to produce more human-readable code, and is very configurable. Then there is **autopep8**, which doesn't change the overall layout of the code, just fix PEP 8 violations. Let's make this a research ticket ("spike") to compare and discuss the options, wdyt? Then we can have a separate ticket for actually implementing the changes. I'd start with linting, that seems less controversial (OTOH, it kinda depends whether we go with all-in-one **ruff**, or a collection of other tools).
kparal self-assigned this 2026-05-15 15:49:31 +00:00
Owner

I've been fine with black, but don't have any particular objections to ruff.

I had the same instinctive reaction to black at first - it wraps things too much! - but after a week or two I realized it does actually usually make things more readable for me that way. In the rare cases when I don't like how it wraps things, I just do it a different way manually; black usually accepts this so long as your alternative is 'clean'.

One thing I learned with any kind of formatter is commit your code changes locally first, then run the formatter. Then its changes are separated from your work. If you run it before committing and you don't like what it does, you've got a bit of a mess to unpick because you can't easily distinguish its changes from your work.

I've been fine with black, but don't have any particular objections to ruff. I had the same instinctive reaction to black at first - it wraps things too much! - but after a week or two I realized it does actually usually make things more readable for me that way. In the rare cases when I don't like how it wraps things, I just do it a different way manually; black usually accepts this so long as your alternative is 'clean'. One thing I learned with any kind of formatter is commit your code changes locally first, *then* run the formatter. Then its changes are separated from your work. If you run it before committing and you don't like what it does, you've got a bit of a mess to unpick because you can't easily distinguish its changes from your work.
Author
Owner

I would say that the two most discussed areas are probably these:

  • line length: black seems to have a default of 88, which in turn produces a long listing of short lines. On the other hand, shorter lines allow us to compare two columns of code side by side easily without side-scrolling. In my experience, we can usually bump this to around 100 characters per line without significant comparability loss. This is also one of the few values black actually allows to change.
  • string quotes: not specified by PEP-8. Although using double-quotes has the advantage that we can then use single-quotes inside a string without any problems, e.g. in SQL statements or as an apostrophe. Double-quotes are also not so easily confused with backticks. Also, PEP-257 mandates use of double-quotes for "triple quoted" multiline strings. Personally, I'd vote for using double-quotes.

Other than that, it would be probably useful to use a tool which allows us to import/export its configuration. We could then create a standardized config containing our agreed values to be included with app source code and enforced by CI.

I would say that the two most discussed areas are probably these: - line length: **black** seems to have a default of 88, which in turn produces a long listing of short lines. On the other hand, shorter lines allow us to compare two columns of code side by side easily without side-scrolling. In my experience, we can usually bump this to around 100 characters per line without significant comparability loss. This is also one of the few values **black** actually allows to change. - string quotes: not specified by PEP-8. Although using double-quotes has the advantage that we can then use single-quotes inside a string without any problems, e.g. in SQL statements or as an apostrophe. Double-quotes are also not so easily confused with backticks. Also, PEP-257 mandates use of double-quotes for "triple quoted" multiline strings. Personally, I'd vote for using double-quotes. Other than that, it would be probably useful to use a tool which allows us to import/export its configuration. We could then create a standardized config containing our agreed values to be included with app source code and enforced by CI.
Author
Owner

I admit I am partial to ruff. I have used it before and never had any significant problem with it. It is very fast. The only area where it might be slightly lacking is maybe type checking and deeper code inspection. So for the CI job we could probably pair it with with a tool like pyright or maybe rather mypy.
Found this page comparing pylint vs ruff speed: https://pythonspeed.com/articles/pylint-flake8-ruff/
@adamwill @kparal What do you think? Would it be OK to continue with ruff?

I admit I am partial to `ruff`. I have used it before and never had any significant problem with it. It is very fast. The only area where it might be slightly lacking is maybe type checking and deeper code inspection. So for the CI job we could probably pair it with with a tool like `pyright` or maybe rather `mypy`. Found this page comparing pylint vs ruff speed: https://pythonspeed.com/articles/pylint-flake8-ruff/ @adamwill @kparal What do you think? Would it be OK to continue with `ruff`?
Owner

Sure, as I said, I don't really have any objection. The speed argument has always made very little sense to me since all these things run so fast - 0.1 seconds versus 2 seconds is not a meaningful difference unless you're linting multiple times per minute, which why would you? - but it's not a reason not to pick ruff either.

Sure, as I said, I don't really have any objection. The speed argument has always made very little sense to me since all these things run so fast - 0.1 seconds versus 2 seconds is not a meaningful difference unless you're linting multiple times per minute, which why would you? - but it's not a reason *not* to pick ruff either.
Owner

I still didn't have time to evaluate the output from different formatters 🙁️ I'll try to get to it. But honestly, I would really like if we focused on linting issues only, and discussed (auto-)formatting separately afterwards. The fact that CI can catch linting errors is very useful. But cosmetic stuff like wrapping lines really is not. Some people might enjoy being bossed around by CI for formatting and whitespace "issues", but I guess I'm not one of them. And if the whole codebase is to be reformatted (and therefore making any git blame more difficult), I'd rather be completely sure that I want that before we do it.

Can Ruff be configured to just care about linting and avoid reformatting lines (or complaining about those)?

In my experience, we can usually bump this to around 100 characters per line

Yeah, we'll need to decide this before any reformatting takes place. I think somewhere between 100-120 would be reasonable.

Although using double-quotes has the advantage that we can then use single-quotes inside a string without any problems

Ruff seems to be pretty intelligent with this:
https://docs.astral.sh/ruff/settings/#format_quote-style

I also like it supports preserve option.

Double-quotes are also not so easily confused with backticks

But backticks are no longer used in Python, so there's little confusion to be had?

I personally use single quotes when possible, I can then read it faster. Double quotes are a duplicated character, it distracts me 😀️ Anyway, with ruff this can be non-enforced.

So for the CI job we could probably pair it with with a tool like pyright or maybe rather mypy.

For blockerbugs I fought a lot with figuring out exclusion rules for mypy, because if often didn't work correctly with SQLAlchemy or Flask or whatever. Or maybe I didn't know how to configure it. Have you tried looking at ty, which is supposed to be better?

Found this page comparing pylint vs ruff speed: https://pythonspeed.com/articles/pylint-flake8-ruff/

AI claims "While Ruff has replaced Flake8 for most linting, Pylint remains popular for "deep" analysis. Ruff is fast because it looks for patterns; Pylint is slower because it actually tries to understand the logic."
So it might still be useful to execute pylint or similar. But again as with the other tools, it seems to be mostly PITA about formatting, drowning actual problems, so configuration would be necessary.


In general, I'd like to hear your opinions on whether you consider cosmetic issues (line length, wrapping, indentation, whitespace, etc) important enough to be blocked by CI, and why. I personally care about real issues (uninitialized variable, comparing a string with is, etc) or soft real issues (unused import, mutable function arguments as defaults, etc). But whether a line is one character over limit, or whether there should be one extra or fewer line between methods, that feels like a waste of time and tool bullying, to me. I would like to understand why it is important to you.

What if we configured the current lint tools to ignore cosmetic problems? With flake8, that can be done extremely quickly, raising signal-to-noise ratio. mypy doesn't produce cosmetic errors, it seems. I'm not sure if somebody can resolve it all quickly, I struggled with it back in the day. But maybe AI can, nowadays. Or consider whether to drop it, if it doesn't work sufficiently well for us (and perhaps replace it with something else in the future). Then we could make the lint step in CI mandatory, and we would be happy (or least happier)? And we can discuss the other stuff (formatting) with lower priority.

I still didn't have time to evaluate the output from different formatters 🙁️ I'll try to get to it. But honestly, I would really like if we focused on linting issues only, and discussed (auto-)formatting separately afterwards. The fact that CI can catch linting errors is very useful. But cosmetic stuff like wrapping lines really is not. Some people might enjoy being bossed around by CI for formatting and whitespace "issues", but I guess I'm not one of them. And if the whole codebase is to be reformatted (and therefore making any git blame more difficult), I'd rather be completely sure that I want that before we do it. Can Ruff be configured to just care about linting and avoid reformatting lines (or complaining about those)? > In my experience, we can usually bump this to around 100 characters per line Yeah, we'll need to decide this before any reformatting takes place. I think somewhere between 100-120 would be reasonable. > Although using double-quotes has the advantage that we can then use single-quotes inside a string without any problems Ruff seems to be pretty intelligent with this: https://docs.astral.sh/ruff/settings/#format_quote-style I also like it supports `preserve` option. > Double-quotes are also not so easily confused with backticks But backticks are no longer used in Python, so there's little confusion to be had? I personally use single quotes when possible, I can then read it faster. Double quotes are a duplicated character, it distracts me 😀️ Anyway, with ruff this can be non-enforced. > So for the CI job we could probably pair it with with a tool like pyright or maybe rather mypy. For blockerbugs I fought a lot with figuring out exclusion rules for mypy, because if often didn't work correctly with SQLAlchemy or Flask or whatever. Or maybe I didn't know how to configure it. Have you tried looking at `ty`, which is supposed to be better? > Found this page comparing pylint vs ruff speed: https://pythonspeed.com/articles/pylint-flake8-ruff/ AI claims "While Ruff has replaced Flake8 for most linting, Pylint remains popular for "deep" analysis. Ruff is fast because it looks for patterns; Pylint is slower because it actually tries to understand the logic." So it might still be useful to execute `pylint` or similar. But again as with the other tools, it seems to be mostly PITA about formatting, drowning actual problems, so configuration would be necessary. ---- In general, I'd like to hear your opinions on whether you consider cosmetic issues (line length, wrapping, indentation, whitespace, etc) important enough to be blocked by CI, and why. I personally care about real issues (uninitialized variable, comparing a string with `is`, etc) or soft real issues (unused import, mutable function arguments as defaults, etc). But whether a line is one character over limit, or whether there should be one extra or fewer line between methods, that feels like a waste of time and tool bullying, to me. I would like to understand why it is important to you. What if we configured the current lint tools to ignore cosmetic problems? With `flake8`, that can be done extremely quickly, raising signal-to-noise ratio. `mypy` doesn't produce cosmetic errors, it seems. I'm not sure if somebody can resolve it all quickly, I struggled with it back in the day. But maybe AI can, nowadays. Or consider whether to drop it, if it doesn't work sufficiently well for us (and perhaps replace it with something else in the future). Then we could make the lint step in CI mandatory, and we would be happy (or least happier)? And we can discuss the other stuff (formatting) with lower priority.
Owner

Personally I like having formatting in CI just because it enforces consistency and removes any need to bikeshed about formatting manually. I don't really find it a waste of time because it takes ten seconds to run the formatter, and I guess I don't feel like it's bullying because I'm not usually particularly attached to however I formatted it manually; the cases where I actually did want it formatted in a 'weird' way, or disagree with how black reformats it, are rare enough that I'm fine just flagging them or reformatting manually.

Personally I like having formatting in CI just because it enforces consistency and removes any need to bikeshed about formatting manually. I don't really find it a waste of time because it takes ten seconds to run the formatter, and I guess I don't feel like it's bullying because I'm not usually particularly attached to however I formatted it manually; the cases where I actually did want it formatted in a 'weird' way, or disagree with how black reformats it, are rare enough that I'm fine just flagging them or reformatting manually.
Author
Owner

I second Adam's opnion regarding formatting being mandatory. What one gets used to having all the code formatted the same way, it is much faster to skim through such code because brain already recognizes some patterns subconsciously. With enough training you will even learn to detect some pain points or bugs just by looking at the code without actually "reading" it.
I grew up with C and Java (and ZX Basic before that), so double-quotes as string delimiters are an obvious choice for me, but I understand where Kamil is coming from. Maybe we could make the formatter ambivalent in this regard.

I second Adam's opnion regarding formatting being mandatory. What one gets used to having all the code formatted the same way, it is much faster to skim through such code because brain already recognizes some patterns subconsciously. With enough training you will even learn to detect some pain points or bugs just by looking at the code without actually "reading" it. I grew up with C and Java (and ZX Basic before that), so double-quotes as string delimiters are an obvious choice for me, but I understand where Kamil is coming from. Maybe we could make the formatter ambivalent in this regard.
Owner

just flagging them

What does this mean?

> just flagging them What does this mean?
Owner

I've played with Ruff today. ruff check is very reasonable, that can be enabled right away. I'm not that fond of ruff format, as could be expected. I used this config:

[tool.ruff]
line-length = 100

[tool.ruff.format]
quote-style = 'preserve'

and tried to reformat blockerbugs/controllers/main.py. It converted original 550 lines into 657 lines.

The first thing that caught my eye is that it loves using parentheses everywhere. When there's a long command, it wraps it in them. The downside is that everything starts to look like a tuple. It requires careful reading to determine whether the resulting value is a function/statement output, or a tuple (all it takes is one command hidden somewhere in the middle, and it complete changes what it is).

For example, original:

recentmods = Bug.query.filter_by(milestone_id=milestoneid).filter(
    Bug.last_bug_sync > modcutoff).all()

Ruff output:

recentmods = (
    Bug.query.filter_by(milestone_id=milestoneid).filter(Bug.last_bug_sync > modcutoff).all()
)

Original (the indentation could be better here, but that's not the point):

updates = Update.query.filter_by(
        release=milestone.release,
    ).join(Update.bugs).filter(
        Bug.milestone == milestone,
        Bug.active.is_(True),
        Bug.is_proposed_accepted.is_(True),
    ).all()

Ruff output:

updates = (
    Update.query.filter_by(
        release=milestone.release,
    )
    .join(Update.bugs)
    .filter(
        Bug.milestone == milestone,
        Bug.active.is_(True),
        Bug.is_proposed_accepted.is_(True),
    )
    .all()
)

The second irking behavior is that for function calls that are longer than one line, it forces a new line for each argument, making the calls outrageously long. When code includes frequent function calls like this one, it requires constant scrolling up and down, you can't fit the important parts on your screen because of this. Furthermore, it promotes bad coding habits. That's because if you omit arg names (value instead of foo=value), the line is shorter, might fit on a single line, and Ruff won't explode it then. But arg names are really nice and useful, they make the code much more readable. So with Ruff, you're motivated to make the code less readable, in order to prevent Ruff to make it also less readable. Ugh... not great?

Original:

return render_template('bug_tooltip.html', packagename=packagename, updates=updates,
                       bz_url=app.config['BUGZILLA_URL'])

Ruff output:

return render_template(
    'bug_tooltip.html',
    packagename=packagename,
    updates=updates,
    bz_url=app.config['BUGZILLA_URL'],
)

Original:

response = make_response(render_template(
    'requests.txt', blocker_updates=blocker_updates, fe_updates=fe_updates,
    milestone=milestone.id, bugs_with_deps=bugs_with_deps, release_num=num))

Ruff output:

response = make_response(
    render_template(
        'requests.txt',
        blocker_updates=blocker_updates,
        fe_updates=fe_updates,
        milestone=milestone.id,
        bugs_with_deps=bugs_with_deps,
        release_num=num,
    )
)

This also happens for lists that are longer than one line, which is even more terrible, because the original lists are perfectly readable as they are.
Original:

update_simple_fields = ['updateid', 'title', 'url', 'karma', 'stable_karma', 'status',
                        'request']

Ruff output:

update_simple_fields = [
    'updateid',
    'title',
    'url',
    'karma',
    'stable_karma',
    'status',
    'request',
]

It affects perfectly readable dicts as well.
Original:

    trackers = {'blocker': selected_milestone.blocker_tracker,
                'fe': selected_milestone.fe_tracker}

Ruff output:

    trackers = {
        'blocker': selected_milestone.blocker_tracker,
        'fe': selected_milestone.fe_tracker,
    }

I also think that the way it handles function definitions makes things less readable, because it keeps the args at the same indent as the main func body. From blockerbugs/models/update.py, compare this:

Original:

def __init__(self,
             updateid: str,
             release: 'model_release.Release',
             status: str,
             karma: int,
             url: str,
             date_submitted: datetime.datetime,
             request: Optional[str] = None,
             title: Optional[str] = None,
             stable_karma: Optional[int] = None,
             bugs: list['bug.Bug'] = []) -> None:
    self.updateid = updateid
    self.release = release
    self.status = status
    self.karma = karma
    self.url = url
    self.date_submitted = date_submitted
    self.request = request
    self.title = title
    self.stable_karma = stable_karma
    self.bugs = bugs

Ruff output:

def __init__(
    self,
    updateid: str,
    release: 'model_release.Release',
    status: str,
    karma: int,
    url: str,
    date_submitted: datetime.datetime,
    request: Optional[str] = None,
    title: Optional[str] = None,
    stable_karma: Optional[int] = None,
    bugs: list['bug.Bug'] = [],
) -> None:
    self.updateid = updateid
    self.release = release
    self.status = status
    self.karma = karma
    self.url = url
    self.date_submitted = date_submitted
    self.request = request
    self.title = title
    self.stable_karma = stable_karma
    self.bugs = bugs

Even this would be an improvement, but still doesn't satisfy Ruff:

def __init__(
        self,
        updateid: str,
        release: 'model_release.Release',
        status: str,
        karma: int,
        url: str,
        date_submitted: datetime.datetime,
        request: Optional[str] = None,
        title: Optional[str] = None,
        stable_karma: Optional[int] = None,
        bugs: list['bug.Bug'] = [],
) -> None:
    self.updateid = updateid
    self.release = release
    self.status = status
    self.karma = karma
    self.url = url
    self.date_submitted = date_submitted
    self.request = request
    self.title = title
    self.stable_karma = stable_karma
    self.bugs = bugs

None of this is a gamestopper, I could live with it (the exploded function calls probably annoy me the most, especially with the reinforcement of not using arg names). It's also not black or white, in some cases the reformatting makes the code a bit more legible indeed. I'm just not convinced this is an overall improvement. If I understand your responses, the biggest benefit is in unification of everything. Ok, but could we unify into something better? 😬

I've played with Ruff today. `ruff check` is very reasonable, that can be enabled right away. I'm not that fond of `ruff format`, as could be expected. I used this config: ``` [tool.ruff] line-length = 100 [tool.ruff.format] quote-style = 'preserve' ``` and tried to reformat `blockerbugs/controllers/main.py`. It converted original 550 lines into 657 lines. The first thing that caught my eye is that it loves using **parentheses everywhere**. When there's a long command, it wraps it in them. The downside is that everything starts to look like a tuple. It requires careful reading to determine whether the resulting value is a function/statement output, or a tuple (all it takes is one command hidden somewhere in the middle, and it complete changes what it is). For example, original: ``` recentmods = Bug.query.filter_by(milestone_id=milestoneid).filter( Bug.last_bug_sync > modcutoff).all() ``` Ruff output: ``` recentmods = ( Bug.query.filter_by(milestone_id=milestoneid).filter(Bug.last_bug_sync > modcutoff).all() ) ``` Original (the indentation could be better here, but that's not the point): ``` updates = Update.query.filter_by( release=milestone.release, ).join(Update.bugs).filter( Bug.milestone == milestone, Bug.active.is_(True), Bug.is_proposed_accepted.is_(True), ).all() ``` Ruff output: ``` updates = ( Update.query.filter_by( release=milestone.release, ) .join(Update.bugs) .filter( Bug.milestone == milestone, Bug.active.is_(True), Bug.is_proposed_accepted.is_(True), ) .all() ) ``` The second irking behavior is that for function calls that are longer than one line, it forces **a new line for each argument**, making the calls outrageously long. When code includes frequent function calls like this one, it requires constant scrolling up and down, you can't fit the important parts on your screen because of this. Furthermore, it promotes bad coding habits. That's because if you omit arg names (`value` instead of `foo=value`), the line is shorter, might fit on a single line, and Ruff won't explode it then. But arg names are really nice and useful, they make the code much more readable. So with Ruff, you're motivated to make the code less readable, in order to prevent Ruff to make it also less readable. Ugh... not great? Original: ``` return render_template('bug_tooltip.html', packagename=packagename, updates=updates, bz_url=app.config['BUGZILLA_URL']) ``` Ruff output: ``` return render_template( 'bug_tooltip.html', packagename=packagename, updates=updates, bz_url=app.config['BUGZILLA_URL'], ) ``` Original: ``` response = make_response(render_template( 'requests.txt', blocker_updates=blocker_updates, fe_updates=fe_updates, milestone=milestone.id, bugs_with_deps=bugs_with_deps, release_num=num)) ``` Ruff output: ``` response = make_response( render_template( 'requests.txt', blocker_updates=blocker_updates, fe_updates=fe_updates, milestone=milestone.id, bugs_with_deps=bugs_with_deps, release_num=num, ) ) ``` This also happens for **lists that are longer than one line**, which is even more terrible, because the original lists are perfectly readable as they are. Original: ``` update_simple_fields = ['updateid', 'title', 'url', 'karma', 'stable_karma', 'status', 'request'] ``` Ruff output: ``` update_simple_fields = [ 'updateid', 'title', 'url', 'karma', 'stable_karma', 'status', 'request', ] ``` It affects perfectly readable dicts as well. Original: ``` trackers = {'blocker': selected_milestone.blocker_tracker, 'fe': selected_milestone.fe_tracker} ``` Ruff output: ``` trackers = { 'blocker': selected_milestone.blocker_tracker, 'fe': selected_milestone.fe_tracker, } ``` I also think that the way it handles function definitions makes things less readable, because it keeps the args at the same indent as the main func body. From `blockerbugs/models/update.py`, compare this: Original: ``` def __init__(self, updateid: str, release: 'model_release.Release', status: str, karma: int, url: str, date_submitted: datetime.datetime, request: Optional[str] = None, title: Optional[str] = None, stable_karma: Optional[int] = None, bugs: list['bug.Bug'] = []) -> None: self.updateid = updateid self.release = release self.status = status self.karma = karma self.url = url self.date_submitted = date_submitted self.request = request self.title = title self.stable_karma = stable_karma self.bugs = bugs ``` Ruff output: ``` def __init__( self, updateid: str, release: 'model_release.Release', status: str, karma: int, url: str, date_submitted: datetime.datetime, request: Optional[str] = None, title: Optional[str] = None, stable_karma: Optional[int] = None, bugs: list['bug.Bug'] = [], ) -> None: self.updateid = updateid self.release = release self.status = status self.karma = karma self.url = url self.date_submitted = date_submitted self.request = request self.title = title self.stable_karma = stable_karma self.bugs = bugs ``` Even this would be an improvement, but still doesn't satisfy Ruff: ``` def __init__( self, updateid: str, release: 'model_release.Release', status: str, karma: int, url: str, date_submitted: datetime.datetime, request: Optional[str] = None, title: Optional[str] = None, stable_karma: Optional[int] = None, bugs: list['bug.Bug'] = [], ) -> None: self.updateid = updateid self.release = release self.status = status self.karma = karma self.url = url self.date_submitted = date_submitted self.request = request self.title = title self.stable_karma = stable_karma self.bugs = bugs ``` None of this is a gamestopper, I could live with it (the exploded function calls probably annoy me the most, especially with the reinforcement of not using arg names). It's also not black or white, in some cases the reformatting makes the code a bit more legible indeed. I'm just not convinced this is an overall _improvement_. If I understand your responses, the biggest benefit is in unification of everything. Ok, but could we unify into something _better_? 😬️
Owner

I've also tested autopep8. With its default invocation, or with aggressiveness level 1, it mostly just touches whitespace, and behaves very civilized in general. I could imagine we use it by default. With aggressiveness level 2, it explodes lists into one item per line as Ruff does, but it at least doesn't touch function calls. Overall I think that autopep8 with aggressiveness level 0 or 1 could be a good contender.

I've also tested **autopep8**. With its default invocation, or with aggressiveness level 1, it mostly just touches whitespace, and behaves very civilized in general. I could imagine we use it by default. With aggressiveness level 2, it explodes lists into one item per line as Ruff does, but it at least doesn't touch function calls. Overall I think that autopep8 with aggressiveness level 0 or 1 could be a good contender.
Author
Owner

@kparal All those ruff examples you consider "terrible", I actually see as "great" :) OK, maybe with the exception of the parentheses thing, which is just a sneaky way of breaking long expressions. Those multi-line lists or function parameters are actually very useful for review diffing or reordering. It is more difficult to visually compare two lists when they are compressed on one line. You can fold those multi-line lists or arguments in your editor and have a neat one-liners regardless.
Again, I am not trying to promote my view as the only solution, just mentioning possible usefulness.

@kparal All those `ruff` examples you consider "terrible", I actually see as "great" :) OK, maybe with the exception of the parentheses thing, which is just a sneaky way of breaking long expressions. Those multi-line lists or function parameters are actually very useful for review diffing or reordering. It is more difficult to visually compare two lists when they are compressed on one line. You can fold those multi-line lists or arguments in your editor and have a neat one-liners regardless. Again, I am not trying to promote my view as the only solution, just mentioning possible usefulness.
Owner

Those multi-line lists or function parameters are actually very useful for review diffing or reordering

I use a per-word diff (meld, pycharm) which makes the changes easy to see. But yes, in a classic line diff it's harder.

You can fold those multi-line lists or arguments in your editor and have a neat one-liners regardless.

In Pycharm, I only seem to be able to compress the whole function, or a code block, but not a function call arguments. Might work in VS Code. But even if it worked, if I fold it, then I don't see the contents, right? I'd just see e.g. render_template(...). I wouldn't want that.

I played with YAPF as well (I spent the whole day on it today 🙁️ ), I'll post my observations next.

> Those multi-line lists or function parameters are actually very useful for review diffing or reordering I use a per-word diff (meld, pycharm) which makes the changes easy to see. But yes, in a classic line diff it's harder. > You can fold those multi-line lists or arguments in your editor and have a neat one-liners regardless. In Pycharm, I only seem to be able to compress the whole function, or a code block, but not a function call arguments. Might work in VS Code. But even if it worked, if I fold it, then I don't see the contents, right? I'd just see e.g. `render_template(...)`. I wouldn't want that. I played with YAPF as well (I spent the whole day on it today 🙁️ ), I'll post my observations next.
Author
Owner

Thanks for spending time on that! Maybe we could also have this discussion at Flock instead of here and now.

With diffing I mean reviews on Forge where it dumps the whole line to diff anyway and then you need to scan the line to find the changed thing (it is highlighted in some way but still). When you have only one thing at the line - well, there is your change.

Guess we could also ask our robot friends what solution would they recommend...

Thanks for spending time on that! Maybe we could also have this discussion at Flock instead of here and now. With diffing I mean reviews on Forge where it dumps the whole line to diff anyway and then you need to scan the line to find the changed thing (it is highlighted in some way but still). When you have only one thing at the line - well, there is your change. Guess we could also ask our robot friends what solution would they recommend...
Owner

So, YAPF seems to stand somewhere in the middle between Black/Ruff and Autopep8. It touches the code much more than autopep8, but less than ruff. It's also highly configurable. Unfortunately it still seems to follow the paradigm of "reformat everything and spit out the result", instead of considering "is this an acceptable chunk of code?" and reformatting only when needed (that way, you'd have some leeway in your formatting).

For the record, I only tested the default pep8 style in YAPF. It contains several other styles to use. But I think they just configure the defaults differently, so I should be able to influence everything even when starting from pep8.

Generally I find YAPF output more readable and closer to the original than Ruff output. For example it doesn't use parentheses to split long lines, like shown above. You can still write your statements directly, add newline after an opening bracket, etc.

I noticed some very weird behavior when dict values contained ternary operators:
Original:

return {'number': milestone.release.number,
        'phase': milestone.version.title(),
        'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None,
        'blocker_tracker': milestone.blocker_tracker,
        'fe_tracker': milestone.fe_tracker
}

YAPF:

return {
    'number':
    milestone.release.number,
    'phase':
    milestone.version.title(),
    'last_updated':
    milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC')
    if milestone.last_bug_sync else None,
    'blocker_tracker':
    milestone.blocker_tracker,
    'fe_tracker':
    milestone.fe_tracker
}

Fortunately this can be fixed by setting allow_split_before_dict_value=False, then it looks more reasonable:

return {
    'number': milestone.release.number,
    'phase': milestone.version.title(),
    'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC')
    if milestone.last_bug_sync else None,
    'blocker_tracker': milestone.blocker_tracker,
    'fe_tracker': milestone.fe_tracker
}

What I'm struggling with is once again function calls. As long as the line is short enough, it keeps the arguments on one line. But one it's longer, it splits then one arg per line.

Original:

return render_template('bug_tooltip.html', packagename=packagename, updates=updates,
                       bz_url=app.config['BUGZILLA_URL'])

YAPF:

return render_template('bug_tooltip.html',
                       packagename=packagename,
                       updates=updates,
                       bz_url=app.config['BUGZILLA_URL'])

This can be remediated by setting split_before_named_assigns=False. Then it looks like Original. However, you can't then decide somewhere else that you want per-line arguments. It munches everything into the condensed form. So this:

Original:

def __init__(self,
             bugid: Optional[int],
             url: Optional[str],
             summary: Optional[str],
             status: Optional[str],
             component: Optional[str],
             milestone: Optional['model_milestone.Milestone'],
             active: Optional[bool],
             needinfo: Optional[bool],
             needinfo_requestee: Optional[str],
             last_whiteboard_change: Optional[datetime.datetime] = datetime.datetime.now(
                 datetime.UTC),
             last_bug_sync: Optional[datetime] = datetime.datetime.now(datetime.UTC),
             depends_on: Optional[list[int]] = None) -> None:

with split_before_named_assigns=False YAPF becomes:

def __init__(self, bugid: Optional[int], url: Optional[str], summary: Optional[str],
                 status: Optional[str], component: Optional[str],
                 milestone: Optional['model_milestone.Milestone'], active: Optional[bool],
                 needinfo: Optional[bool], needinfo_requestee: Optional[str],
                 last_whiteboard_change: Optional[datetime.datetime] = datetime.datetime.now(
                     datetime.UTC), last_bug_sync: Optional[datetime] = datetime.datetime.now(
                         datetime.UTC), depends_on: Optional[list[int]] = None) -> None:

That's terrible. I haven't found a way to be able to use both formatting styles in different places, except using the big hammer of # yapf: disable in some of the places.

Another, more minor pain point is with logical expressions. It reformats them into a condensed form, even if for readability purposes you'd want it otherwise.

Original:

return (self.proposed_blocker or
        self.proposed_fe or
        self.accepted_blocker or
        self.accepted_0day or
        self.accepted_prevrel or
        self.accepted_fe or
        self.prioritized)

YAPF:

return (self.proposed_blocker or self.proposed_fe or self.accepted_blocker
        or self.accepted_0day or self.accepted_prevrel or self.accepted_fe
        or self.prioritized)

There seem to be no configuration options for dealing with logical expressions (there are options for lists, dicts, etc, but not this), so again the only solution seems to be # yapf: disable.

Overall I feel unhappy with YAPF as well, just in a different way than with Ruff. Both of them produce the code alternative of factory-processed meat. The human element is lost in favor of the machine...

So, **YAPF** seems to stand somewhere in the middle between Black/Ruff and Autopep8. It touches the code much more than autopep8, but less than ruff. It's also highly configurable. Unfortunately it still seems to follow the paradigm of "reformat everything and spit out the result", instead of considering "is this an acceptable chunk of code?" and reformatting only when needed (that way, you'd have some leeway in your formatting). For the record, I only tested the default `pep8` style in YAPF. It contains several other styles to use. But I think they just configure the defaults differently, so I should be able to influence everything even when starting from `pep8`. Generally I find YAPF output more readable and closer to the original than Ruff output. For example it doesn't use parentheses to split long lines, like shown above. You can still write your statements directly, add newline after an opening bracket, etc. I noticed some very weird behavior when dict values contained ternary operators: Original: ``` return {'number': milestone.release.number, 'phase': milestone.version.title(), 'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None, 'blocker_tracker': milestone.blocker_tracker, 'fe_tracker': milestone.fe_tracker } ``` YAPF: ``` return { 'number': milestone.release.number, 'phase': milestone.version.title(), 'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None, 'blocker_tracker': milestone.blocker_tracker, 'fe_tracker': milestone.fe_tracker } ``` Fortunately this can be fixed by setting `allow_split_before_dict_value=False`, then it looks more reasonable: ``` return { 'number': milestone.release.number, 'phase': milestone.version.title(), 'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None, 'blocker_tracker': milestone.blocker_tracker, 'fe_tracker': milestone.fe_tracker } ``` What I'm struggling with is once again **function calls**. As long as the line is short enough, it keeps the arguments on one line. But one it's longer, it splits then one arg per line. Original: ``` return render_template('bug_tooltip.html', packagename=packagename, updates=updates, bz_url=app.config['BUGZILLA_URL']) ``` YAPF: ``` return render_template('bug_tooltip.html', packagename=packagename, updates=updates, bz_url=app.config['BUGZILLA_URL']) ``` This can be remediated by setting `split_before_named_assigns=False`. Then it looks like Original. However, you can't then decide somewhere else that you want per-line arguments. It munches everything into the condensed form. So this: Original: ``` def __init__(self, bugid: Optional[int], url: Optional[str], summary: Optional[str], status: Optional[str], component: Optional[str], milestone: Optional['model_milestone.Milestone'], active: Optional[bool], needinfo: Optional[bool], needinfo_requestee: Optional[str], last_whiteboard_change: Optional[datetime.datetime] = datetime.datetime.now( datetime.UTC), last_bug_sync: Optional[datetime] = datetime.datetime.now(datetime.UTC), depends_on: Optional[list[int]] = None) -> None: ``` with `split_before_named_assigns=False` YAPF becomes: ``` def __init__(self, bugid: Optional[int], url: Optional[str], summary: Optional[str], status: Optional[str], component: Optional[str], milestone: Optional['model_milestone.Milestone'], active: Optional[bool], needinfo: Optional[bool], needinfo_requestee: Optional[str], last_whiteboard_change: Optional[datetime.datetime] = datetime.datetime.now( datetime.UTC), last_bug_sync: Optional[datetime] = datetime.datetime.now( datetime.UTC), depends_on: Optional[list[int]] = None) -> None: ``` That's terrible. I haven't found a way to be able to use both formatting styles in different places, except using the big hammer of `# yapf: disable` in some of the places. Another, more minor pain point is with **logical expressions**. It reformats them into a condensed form, even if for readability purposes you'd want it otherwise. Original: ``` return (self.proposed_blocker or self.proposed_fe or self.accepted_blocker or self.accepted_0day or self.accepted_prevrel or self.accepted_fe or self.prioritized) ``` YAPF: ``` return (self.proposed_blocker or self.proposed_fe or self.accepted_blocker or self.accepted_0day or self.accepted_prevrel or self.accepted_fe or self.prioritized) ``` There seem to be no configuration options for dealing with logical expressions (there are options for lists, dicts, etc, but not this), so again the only solution seems to be `# yapf: disable`. Overall I feel unhappy with YAPF as well, just in a different way than with Ruff. Both of them produce the code alternative of factory-processed meat. The human element is lost in favor of the machine...
Owner

With diffing I mean reviews on Forge where it dumps the whole line to diff anyway and then you need to scan the line to find the changed thing (it is highlighted in some way but still).

Yeah, I usually do my diffs locally, because the web diffs are not great (no idea why they don't use word-based diffs instead). For example if there's just indentation change, you can see it easily in proper tools, but the Forge diff is completely unhelpful.

> With diffing I mean reviews on Forge where it dumps the whole line to diff anyway and then you need to scan the line to find the changed thing (it is highlighted in some way but still). Yeah, I usually do my diffs locally, because the web diffs are not great (no idea why they don't use word-based diffs instead). For example if there's just indentation change, you can see it easily in proper tools, but the Forge diff is completely unhelpful.
Owner

Maybe we could make the formatter ambivalent in this regard.

I'd really prefer not to. I don't care whether a codebase uses single or double quotes but I hate when one uses both. Pick one!

> Maybe we could make the formatter ambivalent in this regard. I'd really prefer not to. I don't care whether a codebase uses single or double quotes but I *hate* when one uses both. Pick one!
Owner

@kparal wrote in #306 (comment):

just flagging them

What does this mean?

You can usually tell formatters to ignore specific lines or blocks. I don't know about ruff, but for black see the docs.

@kparal wrote in https://forge.fedoraproject.org/quality/blockerbugs/issues/306#issuecomment-715247: > > just flagging them > > What does this mean? You can usually tell formatters to ignore specific lines or blocks. I don't know about ruff, but for black see [the docs](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#ignoring-sections).
Owner

FWIW I mostly agree with @jgroman , I am fine with almost all the ruff examples you posted. When I started using black I had the same instinctive reaction as you - it spreads things across too many lines! - but over time I came to the same position as @jgroman , it is usually actually better and more readable that way. I think black's rule is more or less that this is OK if the middle line is short enough:

foo(
    arg1, arg2, arg3...
)

otherwise it's one line per argument, and I like that. I like trailing braces always being required to be on a separate line.

The indentation thing for function definitions I think is fine. The trailing brace being back one indent acts as a sufficient separator, for me. I prefer that to making the indents arbitrarily different and having the trailing brace on the same line, I just find that style very hard to read since black nudged me to consistently having braces on a new line.

FWIW I mostly agree with @jgroman , I am fine with almost all the ruff examples you posted. When I started using black I had the same instinctive reaction as you - it spreads things across too many lines! - but over time I came to the same position as @jgroman , it is usually actually better and more readable that way. I think black's rule is more or less that this is OK if the middle line is short enough: ``` foo( arg1, arg2, arg3... ) ``` otherwise it's one line per argument, and I like that. I like trailing braces always being required to be on a separate line. The indentation thing for function definitions I think is fine. The trailing brace being back one indent acts as a sufficient separator, for me. I prefer that to making the indents arbitrarily different and having the trailing brace on the same line, I just find that style very hard to read since black nudged me to consistently having braces on a new line.
Owner

Your ternary case:

return {'number': milestone.release.number,
        'phase': milestone.version.title(),
        'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None,
        'blocker_tracker': milestone.blocker_tracker,
        'fe_tracker': milestone.fe_tracker
}

is for me a textbook case where I'd take the formatter output as a cue to tweak it a bit manually:

lu = milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None
return {
    'number': milestone.release.number,
    'phase': milestone.version.title(),
    'last_updated': lu,
    'blocker_tracker': milestone.blocker_tracker,
    'fe_tracker': milestone.fe_tracker
}

For me, the message I'm getting from the formatter there is "you're doing too much in one place here, slow down, split it out".

Your ternary case: ``` return {'number': milestone.release.number, 'phase': milestone.version.title(), 'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None, 'blocker_tracker': milestone.blocker_tracker, 'fe_tracker': milestone.fe_tracker } ``` is for me a textbook case where I'd take the formatter output as a cue to tweak it a bit manually: ``` lu = milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None return { 'number': milestone.release.number, 'phase': milestone.version.title(), 'last_updated': lu, 'blocker_tracker': milestone.blocker_tracker, 'fe_tracker': milestone.fe_tracker } ``` For me, the message I'm getting from the formatter there is "you're doing too much in one place here, slow down, split it out".
Author
Owner

I also took some time to get used to it but nowadays I usually just tweak the line length and use whatever defaults are provided by the formatter. They are usually sane enough for me.

I also took some time to get used to it but nowadays I usually just tweak the line length and use whatever defaults are provided by the formatter. They are usually sane enough for me.
Owner

Well, there's only logical conclusion here. The AI is already taking over the world, and both of you have been replaced by terminators, who are trying but failing to completely pretend humanity. Help!

For me, the clear winner is autopep8. But since I'm outnumbered and one does not try to negotiate with terminators, I'll try to pretend to be one of you. Glory to the Machine God!

The remaining question is what should the max line length be. What are your orders suggestions? If we're going to reformat the whole codebase, I want to do it just once.

Well, there's only logical conclusion here. The AI is already taking over the world, and both of you have been replaced by terminators, who are trying but failing to completely pretend humanity. Help! For me, the clear winner is autopep8. But since I'm outnumbered and one does not try to negotiate with terminators, I'll try to pretend to be one of you. _Glory to the Machine God!_ The remaining question is what should the max line length be. What are your ~~orders~~ suggestions? If we're going to reformat the whole codebase, I want to do it just once.
Owner

In my local environment (my monitor, my font size, my IDE), line length of 120 chars is too much. I can't comfortably fit side-by-side diff unless I collapse IDE side bars (which is annoying). Also, docstrings with 120 chars seem quite wide to read. So, my suggestion is to pick either 100 or 110 chars. The docs feel a bit better at 100 chars, the code (particularly when heavily nested) at 110, which also avoids line explosion from ruff. But the differences are minor, most of the code I saw didn't exceed 100 chars anyway, even when re-flown to 110. I don't have a strong opinion between the two.

I would use the same limit for both code and comments/docstrings. While it is possible to have a different limit, it's just a complication with setting multiple gutters in editors, etc.

If we re-flow the whole codebase, I'd like comments and docstrings to be re-flown as well. Some of them are currently wrapped at 80 chars, and this would reduce some of the vertical sprawl, improving the readability. But Ruff doesn't support reformatting those (and some of them are tricky, e.g. including lists, where you want to keep newlines). There's a docformatter which might be able to help (as a one-time operation), but manual tweaking will be necessary. Or, just ask an AI agent, I guess, that should be able to do the just as well. Just make sure to instruct it that there must be no wording changes performed.

In my local environment (my monitor, my font size, my IDE), line length of 120 chars is too much. I can't comfortably fit side-by-side diff unless I collapse IDE side bars (which is annoying). Also, docstrings with 120 chars seem quite wide to read. So, my suggestion is to pick either 100 or 110 chars. The docs feel a bit better at 100 chars, the code (particularly when heavily nested) at 110, which also avoids line explosion from ruff. But the differences are minor, most of the code I saw didn't exceed 100 chars anyway, even when re-flown to 110. I don't have a strong opinion between the two. I would use the same limit for both code and comments/docstrings. While it is possible to have a different limit, it's just a complication with setting multiple gutters in editors, etc. If we re-flow the whole codebase, I'd like comments and docstrings to be re-flown as well. Some of them are currently wrapped at 80 chars, and this would reduce some of the vertical sprawl, improving the readability. But Ruff doesn't support reformatting those (and some of them are tricky, e.g. including lists, where you want to keep newlines). There's a [docformatter](https://pypi.org/project/docformatter/) which might be able to help (as a one-time operation), but manual tweaking will be necessary. Or, just ask an AI agent, I guess, that should be able to do the just as well. Just make sure to instruct it that there must be no wording changes performed.
Owner

I use 100 on all my projects. I have some primordial directive deep in my programming that makes me want to wrap docstrings and comments at 72 or 80 chars, though. I don't remember why.

I use 100 on all my projects. I have some primordial directive deep in my programming that makes me want to wrap docstrings and comments at 72 or 80 chars, though. I don't remember why.
Author
Owner

I am also fine with 100.

Regarding docstrings the thing is some IDEs can pick those up and use them for semantic help. For example VSCode does this - when you hover mouse pointer over some method, IDE would pop up the method docstring. It is quite helpful at times. Also maybe some docbuilders reuse docstrings in auto-built docs. I feel in both cases it might be preferable to keep docstrings shorter but I do not have any hard data to back this up.

I am also fine with 100. Regarding docstrings the thing is some IDEs can pick those up and use them for semantic help. For example VSCode does this - when you hover mouse pointer over some method, IDE would pop up the method docstring. It is quite helpful at times. Also maybe some docbuilders reuse docstrings in auto-built docs. I _feel_ in both cases it might be preferable to keep docstrings shorter but I do not have any hard data to back this up.
Owner

Let's go with 100, then. For everything, because it just makes things simpler. If we can demonstrate some problem with comments/docstrings over 80 (there are already cases over 80, it just varies in different places), we can adjust (reflowing comments is not such a PITA as the code, we don't blame comments too much).

Jaroslav, can you prepare a PR? Ideally separate commits/PRs for:

  • Introducing ruff, e.g. in documentation. Perhaps include a hint on how to set up a git pre-commit hook or something (even though Adam said he commits first and then reformats, so perhaps he has a better approach).
  • Reformatting everything (including comments/docstrings).
  • Fixing other linting issues.

Once we see no errors reported, we can even improve (simplify) the current CI workflow.

Let's go with 100, then. For everything, because it just makes things simpler. If we can demonstrate some problem with comments/docstrings over 80 (there are already cases over 80, it just varies in different places), we can adjust (reflowing comments is not such a PITA as the code, we don't blame comments too much). Jaroslav, can you prepare a PR? Ideally separate commits/PRs for: - Introducing ruff, e.g. in documentation. Perhaps include a hint on how to set up a git pre-commit hook or something (even though Adam said he commits first and then reformats, so perhaps he has a better approach). - Reformatting everything (including comments/docstrings). - Fixing other linting issues. Once we see no errors reported, we can even improve (simplify) the current CI workflow.
Author
Owner

There will be one more PR dealing with code linting after existing two PRs are processed and merged.

There will be one more PR dealing with code linting after existing two PRs are processed and merged.
Owner

Adjusted the ticket description to include a checklist

Adjusted the ticket description to include a checklist
Owner

#311 and #312 merged. Now we just need the last PR to fix all errors reported by ./run lint.

#311 and #312 merged. Now we just need the last PR to fix all errors reported by `./run lint`.
kparal referenced this issue from a commit 2026-06-15 20:03:05 +00:00
Owner

Fully fixed in #317

Fully fixed in #317
Sign in to join this conversation.
No milestone
No project
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Depends on
Reference
quality/blockerbugs#306
No description provided.