Skip to content

Make project compatible with Python 3.7+ #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Feb 6, 2023
Merged

Make project compatible with Python 3.7+ #37

merged 23 commits into from
Feb 6, 2023

Conversation

kytta
Copy link
Contributor

@kytta kytta commented Jan 7, 2023

Closes #35

@kytta
Copy link
Contributor Author

kytta commented Jan 7, 2023

So far, it's a lot of find-and-replace work. I have replaced the types, but get_origin() needs to be replaced, too. As of now, it's 447 tests failed, 12 tests passed :)

kytta added 7 commits January 7, 2023 12:57
On error, the older versions of argparse call the error() method, which
is already overridden in a way that we like. So, to fix the tests, we
just do not provide the parameter to the constructor on older Python
versions.
@kytta
Copy link
Contributor Author

kytta commented Jan 7, 2023

Alright, I have resolved almost everything that needed to be resolved. There is one last problem, though.

The test argparse.test_parser.py::test_invalid_arguments fails when a subparser doesn't have a dest. It happens right on the last step, where the error message gets generated. This was a known bug (see python/cpython#73484) which was fixed in 3.11 (python/cpython#3680) and backported to 3.9 (python/cpython#27304). Since it's a module function, we can't reliably backport/patch it.

@SupImDos any ideas what we can do here? I propose adding a default metavar to the SubParsersAction with the value of, e.g., "COMMAND":

class SubParsersAction(argparse._SubParsersAction):
+    def __init__(self, *args, **kwargs):
+        kwargs.setdefault("metavar", "COMMAND")
+        super().__init__(*args, **kwargs)

Basically, this would be analogue to this:

parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(metavar="COMMAND", required=True)
test_sub = subparsers.add_parser("test")
test_sub.add_argument("--flag", type=bool, default=False)
$ python3.7 parsing.py --help
usage: parsing.py [-h] COMMAND ...

positional arguments:
  COMMAND

optional arguments:
  -h, --help  show this help message and exit

$ python3.7 parsing.py       
usage: parsing.py [-h] COMMAND ...
parsing.py: error: the following arguments are required: COMMAND
The same code under Python 3.7, but without metavar
$ python3.7 parsing.py --help
usage: parsing.py [-h] {test} ...

positional arguments:
  {test}

optional arguments:
  -h, --help  show this help message and exit

$ python3.7 parsing.py       
Traceback (most recent call last):
  File "parsing.py", line 9, in <module>
    parser.parse_args()
  File "/Users/nikita/.pyenv/versions/3.7.16/lib/python3.7/argparse.py", line 1755, in parse_args
    args, argv = self.parse_known_args(args, namespace)
  File "/Users/nikita/.pyenv/versions/3.7.16/lib/python3.7/argparse.py", line 1787, in parse_known_args
    namespace, args = self._parse_known_args(args, namespace)
  File "/Users/nikita/.pyenv/versions/3.7.16/lib/python3.7/argparse.py", line 2022, in _parse_known_args
    ', '.join(required_actions))
TypeError: sequence item 0: expected str instance, NoneType found

@SupImDos
Copy link
Owner

SupImDos commented Jan 9, 2023

@kytta
Wow, thanks so much for all your hard work. This looks great!

1. Minor Type Checking Error

Looks like we might need to add a type: ignore[union-attr] to the BooleanOptionalAction backport so the CI/CD passes. The Python standard library typing is always a bit dodgy.

https://github.com/kytta/pydantic-argparse/blob/python-37/pydantic_argparse/argparse/actions.py#L194

-            setattr(namespace, self.dest, not option_string.startswith('--no-'))
+            setattr(namespace, self.dest, not option_string.startswith('--no-'))  # type: ignore[union-attr]

2. Argparse SubParser Backport Bug

Hmm this is an interesting one. Ideally I would like to keep the same behaviour (i.e., show the different command options across the different versions. i.e.,

$ python examples/commands.py
usage: Example Program [-h] [-v] [--verbose] {build,serve} ...
Example Program: error: the following arguments are required: {build,serve}

I can think of a few things we can do:

  1. Default Static Metavar
    As you suggest above, we can provide a default/dummy metavar to the SubParsersAction.

    pydantic_argparse/argparse/actions.py

    class SubParsersAction(argparse._SubParsersAction):
    +    def __init__(self, *args, **kwargs):
    +        kwargs.setdefault("metavar", "COMMAND")
    +        super().__init__(*args, **kwargs)

    However, this means the help does not display the actual options we have (i.e., in this case {build,serve} - just the COMMAND string.

    $ python examples/commands.py
    usage: Example Program [-h] [-v] [--verbose] COMMAND ...
    Example Program: error: the following arguments are required: COMMAND
  2. Dynamic Metavar Using Property
    Another option we have is to hijack the metavar value using a @property. This way we can dynamically construct the string with self.choices to provide the desired {build,serve} string. It is a bit messy though, and we have to provide a dummy @metavar.setter method. Not sure if I really like this option.

    pydantic_argparse/argparse/actions.py

    class SubParsersAction(argparse._SubParsersAction):
    +   @property
    +   def metavar(self) -> Optional[str]:
    +       """Docstring...
    +
    +       Returns:
    +           Optional[str]: ...
    +       """
    +       # Explanation?
    +       # Type checking?
    +       # What if no `self.choices`?
    +       return '{' + ','.join(self.choices) + '}'
    +
    +   @metavar.setter
    +   def metavar(self, value: Any) -> None:
    +       """Docstring...
    +
    +       Args:
    +           value (Any): ...
    +       """
    +       # Dummy method, we can't allow it to be set
    +       return None

    The desired result is produced below.

    $ python examples/commands.py
    usage: Example Program [-h] [-v] [--verbose] {build,serve} ...
    Example Program: error: the following arguments are required: {build,serve}
  3. Backport and MonkeyPatch _get_action_name
    Finally, we could provide our own monkey-patch for the fixed _get_action_name method as below. This would provide the desired functionality and be relatively clean? I think I like this, but I'm not sure if we might introduce some issues by monkey-patching the standard library.

    pydantic_argparse/argparse/patches.py

    """Docstring..."""
    
    
    # Standard
    import argparse
    import sys
    
    # Typing
    from typing import Optional
    
    
    # Some comments here explaining why we do this
    # https://github.com/python/cpython/blob/main/Lib/argparse.py#L739-L751
    if sys.version_info < (3, 9):
        def _get_action_name(argument: Optional[argparse.Action]) -> Optional[str]:
            """Docstring."""
            if argument is None:
                return None
            elif argument.option_strings:
                return '/'.join(argument.option_strings)
            elif argument.metavar not in (None, argparse.SUPPRESS):
                return argument.metavar  # type: ignore[return-value]
            elif argument.dest not in (None, argparse.SUPPRESS):
                return argument.dest
            elif argument.choices:
                return '{' + ','.join(argument.choices) + '}'
            else:
                return None
    
        # Monkey-Patch
        argparse._get_action_name = _get_action_name

    The desired result is produced below.

    $ python examples/commands.py
    usage: Example Program [-h] [-v] [--verbose] {build,serve} ...
    Example Program: error: the following arguments are required: {build,serve}

@kytta I think at the moment I prefer (3). What do you think?

@kytta
Copy link
Contributor Author

kytta commented Jan 9, 2023

Looks like we might need to add a type: ignore[union-attr] to the BooleanOptionalAction backport so the CI/CD passes.

Yup, done!


2. Argparse SubParser Backport Bug

Hmm this is an interesting one. Ideally I would like to keep the same behaviour (i.e., show the different command options across the different versions.

  1. Default Static Metavar

This would be my favourite way to do this, but I understand that having every command as a metavar is more helpful than just "COMMAND".

  1. Dynamic Metavar Using Property

Too messy, such that the code becomes less readable.

  1. Backport and MonkeyPatch _get_action_name

I am usually not a fan of monkey patching module functions, but this is pretty clean, and we can turn it off on newer Python versions with sys.version_info check.

@kytta I think at the moment I prefer (3). What do you think?

Yeah, let's do that.

@kytta
Copy link
Contributor Author

kytta commented Jan 9, 2023

@SupImDos another thing I wanted to ask: How do you like typing_extensions and importlib_metadata to be imported? They currently use two different approaches, and I want to use the same one for both of them:

  1. Version-checked. We would only install the backports on those Python versions, where they're needed, and check the version at runtime:

    # foobar.py
    if sys.version < (3, 8):
        import importlib_metadata as metadata
    else:
        from importlib import metadata
    # pyproject.toml
    
    [tool.poetry.dependencies]
    # [...]
    importlib_metadata = { version = ">=4", python = "<3.8" }
  2. Always import. We don't care about the Python version and always use the backport:

    # foobar.py
    import importlib_metadata as metadata
    # pyproject.toml
    
    [tool.poetry.dependencies]
    importlib_metadata = ">=4"

I like the (1) better, as it allows us to ship less dependencies to the most users (as I don't think that many people use Python 3.7 in production, especially for command-line-apps). It is also very easy to remove later with pyupgrade. But the code is cleaner with the second option 🤔

@SupImDos
Copy link
Owner

SupImDos commented Jan 9, 2023

How do you like typing_extensions and importlib_metadata to be imported?

  1. Version-checked. We would only install the backports on those Python versions, where they're needed, and check the version at runtime...

This feels like the correct way to do it. As you say, it allows us to ship less dependencies to the most users.

  1. Always import. We don't care about the Python version and always use the backport...

Less code branching is a lot cleaner and it is tempting. But I think (1) is still better in a practical sense.

@kytta I agree with you that we should go with (1) 😃

I am interested to see how the code coverage is calculated with branches that only execute in certain Python versions. We will have to see 🤔

@kytta
Copy link
Contributor Author

kytta commented Jan 9, 2023

I am interested to see how the code coverage is calculated with branches that only execute in certain Python versions. We will have to see 🤔

This is also an interesting moment.

A while ago I have noticed the comment # pragma: <3.8, cover somewhere, and it was supposed to disable coverage on the branches on specific Python repos. I have used it in my project, and it worked.

Now, I have tried to also use it here (see commit c2bcc6c), but it didn't work (at least locally). For me, poe test reports missing lines, these version checks to be exact.

I then tried to look this pragma up, but neither coverage nor pytest-cov docs mention anything other than pragma: no cover

If we can't get this to work, I propose setting pragma: no cover on lines for Python 3.7–3.8 and only run coverage on the newer versions

@SupImDos
Copy link
Owner

SupImDos commented Jan 9, 2023

@kytta
I've done a bit of a look into the # pragma: >x.y cover and I think I might have worked it out. As far as I can tell it looks like it comes from the covdefaults package.

I had a look at your venvy project which uses # pragma: <3.8 cover and # pragma: >=3.8 cover, and it looks like it has covdefaults installed.

I think this works? covdefaults looks pretty useful. Maybe we should do:

  1. poetry add --group dev covdefaults
  2. Modify pyproject.toml
    [tool.coverage.run]
    - branch = true
    + plugins = ["covdefaults"]
    -
    - [tool.coverage.report]
    - show_missing = true
    - exclude_lines = [
    -   "if __name__ == .__main__.:",
    - ]

Does this sound right? What do you think?

@kytta
Copy link
Contributor Author

kytta commented Jan 9, 2023

@kytta I've done a bit of a look into the # pragma: >x.y cover and I think I might have worked it out. As far as I can tell it looks like it comes from the covdefaults package.

I had a look at your venvy project which uses # pragma: <3.8 cover and # pragma: >=3.8 cover, and it looks like it has covdefaults installed.

I think this works? covdefaults looks pretty useful. Maybe we should do:

1. `poetry add --group dev covdefaults`

2. Modify `pyproject.toml`
   ```diff
   [tool.coverage.run]
   - branch = true
   + plugins = ["covdefaults"]
   -
   - [tool.coverage.report]
   - show_missing = true
   - exclude_lines = [
   -   "if __name__ == .__main__.:",
   - ]
   ```

Does this sound right? What do you think?

Oh right, I completely forgot about this package! Yeah, this looks good, I'll add this to pyproject.toml

@kytta
Copy link
Contributor Author

kytta commented Jan 17, 2023

Alright, everything is fixed, and the only thing left is the coverage for some of the backports:

pydantic_argparse/argparse/actions.py:160

pydantic_argparse/argparse/actions.py:193

pydantic_argparse/argparse/patches.py:39

Should we even cover those? I mean, it's not even our code... 🤔

@kytta kytta marked this pull request as ready for review January 17, 2023 14:40
@SupImDos
Copy link
Owner

@kytta
Thanks so much, this looks great!

Alright, everything is fixed, and the only thing left is the coverage for some of the backports
Should we even cover those? I mean, it's not even our code... 🤔

You're right, I would be happy with a # pragma: no cover for now, as you say its not our code anyway. I think the CI/CD is only failing because of the <100% coverage (which will be fixed by the pragmas) and a whitespace issue in patches.py.

I'll do my best to review this properly as soon as I can 😃

@kytta
Copy link
Contributor Author

kytta commented Feb 4, 2023

Alright, should be done now! All checks run fine on my machine 👌🏻

Copy link
Owner

@SupImDos SupImDos left a comment

Choose a reason for hiding this comment

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

Looks great, thanks again for all this work!

@SupImDos SupImDos merged commit 1397310 into SupImDos:master Feb 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Consider supporting older Python versions?
2 participants