Should `shlex.quote` be changed to not return `"''"` for falsey non-`str` data

Summary

I think it should be discussed whether shlex.quote should be changed to raise TypeError for falsey non-str data, by moving the type check to the top of the function, before the empty-string special-case check. It caught my eye when implementing #148846 and it sticks out as a (potentially bad) cause of silent failures, most considerably whether None should continue to be coerced to "''".

Current:

def quote(s):
    if not s:
        return "''"

    if not isinstance(s, str):
        raise TypeError(f"expected string object, got {type(s).__name__!r}")
    ...

Proposal to discuss (just swap the order):

def quote(s):
    if not isinstance(s, str):
        raise TypeError(f"expected string object, got {type(s).__name__!r}")

    if not s:
        return "''"
    ...

History:

PR #132036 addressed Issue #118761 to speed up the import time of shlex, but by doing so changed what exception was thrown when the wrong type of argument for s (anything but str) was passed to shlex.quote. Before commit 06a26fda of #132036 a TypeError was thrown by re.Pattern.search. In 06a26fda or newer shlex.quote tried to access the (str.)isascii attribute of s, which threw an AttributeError for s of non-str type.

This was observed in Issue #138804 and resolved by PR #138809 (commit 220c0a81), by adding an isinstance(s, str) check towards the top of the function which raises a TypeError early lest an AttributeError be raised by trying to access s.isascii, or perhaps even worse s.isascii() and ... being called on s of a type that implements isascii differently than str.

The isinstance check was originally going to go at the top of shlex.quote, but it was discussed in a review of #138809 that having shlex.quote raise TypeError for falsey non-str data would technically be a breaking change, because the existing behavior both before and after #132036 is to return "''" for when s is falsey, eg for None or False or 0, regardless of whether the type of s made sense to be passed to quote.

def quote(s):
    if not s:
        return "''"
    ...

Thus the implementation left the special case of returning "''" for falsey data at the top of the function, before the type check, even though really it is intended specifically to test whether the empty string of length 0 has been passed, which needs to be treated as a special case in string quoting; the boolean check of if not s is just a Pythonic shortcut around if len(s) == 0, the latter of which is certainly not intended (in my eyes) to silently fail for None or False or int data which do implement __bool__, but do not implement __len__.

The documentation for shlex.quote only mentions strings, and does not indicate that the coincidental behavior for non-str data is intended.

Compatibility

Since we have established that if not s was intended to check for the str of length 0 via if len(s) == 0, not just to check for any falsey argument s, it remains to ask “is there anyone intentionally relying on shlex.quote returning the string "''" (the string of length 2 consisting of two single quotes) for arbitrary falsey data?”.

I would guess no with one exception (None) that we will discuss later. For now we eliminate all other cases:

For s whose type is any class that doesn’t implement __bool__, s is true-y, that is the if not s check always returns False, and TypeError is then raised as expected. We therefore inspect falsey builtins:

If one were to imagine that someone out there incorrectly thinks that shlex.quote is for another type proximal to str, eg bytes, surely they would have passed a non-zero length string of bytes to shlex.quote by now, which causes TypeError to be raised, which would prompt them to change to using str? If not then their code is purely a b"" to "''" machine which doesn’t seem to have any real uses, observing the difference in types. Therefore I wouldn’t feel bad about making a ‘breaking change’ of raising TypeError even for that empty bytes string.

I can imagine False being passed to quote only as a user error, eg if one wrote shlex.quote(s.islower()) instead of shlex.quote(s.lower()). However since the True case of str.islower would cause a TypeError, and shlex.quote("ABC".islower()) returns an "''" which one notices is not "abc", I don’t think this is likely to be in anyone’s working code.

Nobody is passing ints to shlex.quote right? Why should 0 → "''"?

Therefore the main point I can see that needs debating is shlex.quote(None)'s behavior of coercing None to "''".

Should None continue to be coerced to "''"?

Continued post…

2 Likes

eg 1

I can maybe try to think of situations where one is quoting a list[str | None], eg reading usernames, ages, and optional comments out of a dataclass or something, and a lazy developer using

argv = ["db", "insert"]
argv.extend(["--username", shlex.quote(user.username)])
argv.extend(["--age",      shlex.quote(str(user.age))])
argv.extend(["--comment",  shlex.quote(user.comment)])

to mimic

$ db insert --username 'foo' --age '67' --comment ''

when really they should have looked at if the comment field is None:

argv = ["db", "insert"]
argv.extend(["--username", shlex.quote(user.username)])
argv.extend(["--age",      shlex.quote(str(user.age))])
if user.comment is not None:
    argv.extend(["--comment",  shlex.quote(user.comment)])

but surely the latter case is the most likely anyways, because if one is paying attention to age being an int which needs converting to a str, then one would also pay attention to comment being optional, and not wanting to clog one’s database with tonnes of empty comment entries?

eg 2

Plenty of coreutils like ls, touch, mkdir, ln, rm, find gently exit with failure and perror of ENOENT when doing eg $ mkdir '' so I don’t think None being coerced to "''" is super bad (any security vulnerabilities), but maybe there’s less gentle failures, like testing the length of a string vs a string being unset causing issues on a command line.

Anyone else got opinions?

Is that really the intention, or would the intention be better expressed as if s == "" ?

3 Likes

Quoting of None is not silent for everybody. mypy users do get an error:

error: Argument 1 to "quote" has incompatible type "None"; expected "str"  [arg-type]

In my (irrelevant) opinion, transforming an error (even if mypy-only) to a supported special case is a worse option than the other two (do nothing; raise TypeError).

pyright and ty also has the input argument as str.

Changing it to if len(s) == 0 or if s == ““ (I think the 2nd if is the better choice) probably shouldn’t effect many “modern” projects. It also isn’t documented “anywhere” (see the next paragraph) so anyone relying on the current behavior are probably using it unintentionally. There should probably also be as little “unintended behavior” in shell quoting as possible.

However, searching for shlex.quote(None) on github (https://grep.app/search?f.lang=Python&q=shlex.quote(None)) only yields results in a test for cpython and rustpython. So, on some level, shled.quote(None) == “‘‘“ is expected/desired behavior. On the other hand, that means that people aren’t “directly” relying on that behavior (though, someone with better github-foo than me could better check if someone is passing None in).

Although rejecting non-str types before the falsy check is

  • contractually ok, in terms of what shlex says it supports
  • an improvement at runtime for some cases
  • conceptually “cleaner” (debatable, but I hold this to be true)

I don’t see a strong enough motivation to change it.

I would personally like this change but think it is not justifiable. It will probably break someone, and we don’t (IMO) gain enough to be worth it.

1 Like

Changing quote to

def quote(s):
    if s == "":
        return "''"

    if not isinstance(s, str):
        raise TypeError(f"expected string object, got {type(s).__name__!r}")
    ...

is equivalent (for any class that doesn’t have __eq__ chicanery for "") to

def quote(s):
    if isinstance(s, str) and not s:
        return "''"

    if not isinstance(s, str):
        raise TypeError(f"expected string object, got {type(s).__name__!r}")
    ...

which is equivalent to the proposed

def quote(s):
    if not isinstance(s, str):
        raise TypeError(f"expected string object, got {type(s).__name__!r}")

    if not s:
        return "''"

anyways, not the current implementation, because the if s == "" returns False for s = None and causes TypeError to be raised, which isn’t the current behavior, but in my eyes should be.

So tldr: changing if not s to if s == "" is the same behavior as the proposed swapping of order of type checking and if not s checking, to throw TypeError for falsey non-str data. I think the swapping of the checks makes it much clearer what’s intended to be passed to quote if we were to pick one of those two implementations, rather than leaving quote as it is.

Yeah, I was assuming that the early check was intended as a fast path, and so it had a reason for existing.

True there’s not any new features being roadblocked by the current behavior, nor any new features gained by changing it.

All that we’d be doing is strengthening the guardrails against calling quote with wonky arguments in future use, but at the expense of anyone who’s already relying on the current behavior of wonky-but-works arguments (None → "''").

The main potential for breakage I can think of, as I put above, is someone relying on quote implicity coercing None to the empty string (""), and then quoting it ("''"), both steps done in one call of quote().

eg: if someone is doing my_dict.get(optional_field_key) instead of my_dict.get(optional_field_key, "") and passes the result to quote, either out of laziness (read as ‘acceptable convenience’), or mistake, or believing that’s what quote is intended to do, regardless of whether it’s documented.

or eg: a user purposely uses dict.get(optional_field_key) without a default (whence None is returned) because they want to convey the difference between a missing field and a present empty field, when passing it to a library that employs shlex.quote for them, the user never seeing shlex.quote directly. If the library is ‘finished software’ and isn’t maintained, and this breaking change occurs in the Python stdlib changing shlex.quote to raise TypeError, then there’s not really a way for the user to downgrade their program; either they have to version-pin Python itself or go delving into that library, which may be a complicated task that they don’t want to do. They may not be able to pass "" instead of None to the library because of side effects. I know this argument is a bit fearmonger-y on a hypothetical library, but breaking changes are breaking!

If one really wanted there could be a deprecation phase via

def quote(s):
    if s is None:
        import warnings
        warnings.warn(
            f"expected string object, got {s!r}",
            DeprecationWarning, stacklevel=2
        )
        return "''"

    if not isinstance(s, str):
        raise TypeError(f"expected string object, got {type(s).__name__!r}")

    if not s:
        return "''"
    ...

and tests changed to

-        self.assertEqual(shlex.quote(None), "''")
+        with self.assertWarns(DeprecationWarning):
+            self.assertEqual(shlex.quote(None), "''")

or should this quirk be left alone; it’s not a bug, it’s a feature!

I’d be fine with leaving it personally, but at least now there’s this discussion thread in case anyone else notices this behavior and wants to read / discuss opinions on it. Thanks! :smiley:

1 Like

I wouldn’t focus too narrowly on None. Any falsy value, e.g. an empty list, works today.

IMO any code which relies on this behavior is bugged. But before making a change which intentionally breaks buggy client code, we should weigh the harm to orgs and people who rely on the bug against the benefits to maintainers and future users. I see only weak benefits.

There is no bug in shlex.quote itself. The new version is no easier to maintain. Users with correct usage see no gains. Only new users are helped, if they introduce the exact bug which silently succeeds today.

It’s possible that adding a deprecationWarning could actually help people? I mean, if their code is buggy that could help them find & fix the bug. And it does open up the path to deprecate quote([]) fully 5 years from now.

Yes, it’s definitely possible to add a warning. But what does changing this behavior – either immediately with a hard break or gradually with a warning – help users do?

It seems to me that the main benefit is “we can help users catch the exact bug which is a silent success today”. Which is actually circular because we’re choosing, by supporting this usage or not, whether or not to continue to clarify such usage as a bug.

Who has a subpar or bad experience today, and would be helped by this change? (Maintainers count, but I don’t think they are helped.)

3 Likes

I’ve opened GitHub issue gh-149280 which links to this discussion, just for the record, incase someone else searches for this observation on GitHub. My git tags to the reference implementation for both the hard-break and soft-deprecation routes are on there.

The manual raising of TypeError was just about making the type of exception raised for data of an incorrect type consistent between versions. The behavior of quote for falsey non-str data is just an artefact of not wanting to break compatibility, not purposely trying to permit a special case.

I can take it or leave it wrt to implementing the soft-deprecation. The tags are there if one wants to make a branch out of them.