Skip to content
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

Add type annotations #6

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open

Add type annotations #6

wants to merge 4 commits into from

Conversation

jtojnar
Copy link
Contributor

@jtojnar jtojnar commented May 24, 2023

While trying to wrap my head around the codebase to debug #5, I started adding type hints. Posting it here in case you find it useful.

Copy link
Owner

@mgedmin mgedmin left a comment

Choose a reason for hiding this comment

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

Thank you!

To be complete, this PR needs

  • a fix for CI failures (test coverage claims line 121 is not tested, the abstract method needs a pragma: nocover)
  • a CHANGES.rst note about the dropped Python 2.7 support

To avoid bitrot of type annotations I would also strongly like to have

The comments on the diff about my preferences are not blockers.

tests.py Outdated Show resolved Hide resolved
strace_process_tree.py Outdated Show resolved Hide resolved
strace_process_tree.py Show resolved Hide resolved
@mgedmin
Copy link
Owner

mgedmin commented May 24, 2023

a CHANGES.rst note about the dropped Python 2.7 support

Please do a git pull before touching that file, because I just released 1.3.0 with a bugfix, so now there's a new header in there.

@jtojnar jtojnar force-pushed the types branch 2 times, most recently from de43671 to dc82767 Compare May 24, 2023 10:24
@jtojnar
Copy link
Contributor Author

jtojnar commented May 24, 2023

I added a GitHub action however Mypy does not really like the dynamic __getattr__ dispatch:

strace_process_tree.py:489: error: Cannot instantiate abstract class "Theme" with abstract attribute "__getattr__"  [abstract]

I can do one of the following:

  • Change it back to non-abstract class (and raise NotImplemented)

  • Use # type: ignore annotation when creating Theme in main

  • Create real formatting methods explicitly.

  • Create real formatting methods using descriptors

    class AnsiTheme(Theme):
        normal = AnsiStyle('\033[m')
        red = AnsiStyle('\033[31m')
        green = AnsiStyle('\033[32m')
        blue = AnsiStyle('\033[34m')

@jtojnar jtojnar force-pushed the types branch 3 times, most recently from 1ac7200 to fb985a7 Compare May 24, 2023 15:00
@jtojnar
Copy link
Contributor Author

jtojnar commented May 24, 2023

I came up with a cleaner descriptor-like API without the use of descriptors.

@jtojnar jtojnar force-pushed the types branch 2 times, most recently from de2ad55 to d5bd6d3 Compare May 24, 2023 16:31
Copy link
Owner

@mgedmin mgedmin left a comment

Choose a reason for hiding this comment

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

The PR is getting big and perhaps ought to be split up?

I'm not sold on the Theme refactoring.

CI seems to be broken.

Makefile Outdated Show resolved Hide resolved
pyproject.toml Outdated Show resolved Hide resolved
tox.ini Outdated Show resolved Hide resolved
self.tree_style = getattr(self, styles.get("tree_style", "normal"))
self.pid = getattr(self, styles.get("pid", "red"))
self.process = getattr(self, styles.get("process", "green"))
self.time_range = getattr(self, styles.get("time_range", "blue"))
Copy link
Owner

Choose a reason for hiding this comment

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

Clearly I went overboard with the Theme magic. It was an experiment to see how to add configurable color highlighting without affecting too much of the code. I still like passing around Theme objects and invoking theme.thing() before printing things. I regret abusing Theme.__new__ instead of writing a standalone def pick_theme(unicode: Optional[bool], color: Optional[bool]) -> Theme.

I'm undecided about the magic __getattr__. I'd like to study the problem of mypy dealing with __getattr__ in greater detail, which will require me to find a block of time.

Copy link
Contributor Author

@jtojnar jtojnar May 26, 2023

Choose a reason for hiding this comment

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

The first issue is that self.ctlseq is not defined in Theme so Theme.__getattr__() cannot refer to it.

I can move Theme.__getattr__() to AnsiTheme.__getattr__() but then it will not be possible to call it on Theme values.

I can resolve that by having Theme.__new__() return Union[AnsiTheme, PlainTheme instead of Theme which will probably satisfy mypy but the dynamic declaration of style properties still makes the code harder to reason about than necessary. Which is why I propose moving the style properties to class/object instantiation so that they are obvious up front.

Copy link
Owner

Choose a reason for hiding this comment

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

Would something like

StyleName = str
ColorName = str

class StyleProperty:
    name: StyleName
    def __get__(...): ...
    def __set_name__(...): ...

class Theme:
    pid = StyleProperty()
    process = StyleProperty()
    ....
    reset: str = ''
    colors: Dict[ColorName, str] = {}
    styles: Dict[StyleName, ColorName] = {}

class PlainTheme(Theme):
    pass

class AnsiTheme(Theme):
    reset = ''
    colors = {
        'red': ...
    }
    styles = {
        'pid': 'red',
    }

def pick_theme(...) -> Theme:
    ...

work? With some magic in StyleProperty.get that needs to return a callable (sort of like a custom bound method) that remembers which theme it came from, and the name of the style property, and can look up theme.colors.get(theme.styles.get(style_name), '') and then wrap the argument between it and theme.reset?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I do not see a benefit over the current proposal.

Other than pick_theme() being a function rather than class method of ThemeFactory. But then we would need to either define the helpers like should_use_color inside the pick_method function, or move them outside, losing the grouping and encapsulation provided by the class.

if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
Copy link
Owner

Choose a reason for hiding this comment

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

Ah, CI claims this line is not tested (on 3.8).

I usually do

try:
    from typing import Literal  # Python >= 3.8
except ImportError:
    from typing_extensions import Literal

and mention except ImportError in .coveragerc's exclude_lines.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Owner

Choose a reason for hiding this comment

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

Curious: how does mypy complain about the try/except version?

Copy link
Owner

Choose a reason for hiding this comment

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

FWIW I'm not married to the try/except suggestion. I low-key like it because it requires only one pragma to satisfy coverage.py, as the import statement inside the try block is always executed anyway. The sys.version check would # pragma: nocover comments on both import statements.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

strace_process_tree.py:37: error: Incompatible import of "Literal" (imported name has type "typing_extensions._SpecialForm", local name has type "<typing special form>")  [assignment]

return True


def test_Theme_is_terminal_no_it_is_not(capsys):
assert not stp.Theme.is_terminal()
def test_Theme_is_terminal_no_it_is_not(capsys: CaptureFixture[str]) -> None:
Copy link
Owner

Choose a reason for hiding this comment

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

Wow, that's a lot of work. I've never tried to add type annotations to my tests before.

Anyway, mypy complains:

tests.py:5: error: Cannot find implementation or library stub for module named "pytest"  [import]
tests.py:5: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
tests.py:80: error: Untyped decorator makes function "test_parse_timestamp" untyped  [misc]

Copy link
Owner

Choose a reason for hiding this comment

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

tests.py:80: error: Untyped decorator makes function "test_parse_timestamp" untyped [misc]

I wonder if installing types-pytest would help with this. Assuming types-pytest exists. If not, we may have to abandon the idea of type-checking the test suite, because I'm not willing to give up @pytest.mark.parametrize().

Copy link
Contributor Author

@jtojnar jtojnar May 26, 2023

Choose a reason for hiding this comment

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

Wow, that's a lot of work. I've never tried to add type annotations to my tests before.

Most of that was done with search and replace so it was fine.

Anyway, mypy complains:

It passes for me locally. Maybe tox just needs to install pytest, trying that.

Copy link
Owner

Choose a reason for hiding this comment

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

Whoops, I forgot that tox -e mypy is a different environment that doesn't have pytest installed.

(I'm suddenly seeing the point of having a 'test' extra that depends on pytest, that I used to find a bit weird when I saw it used by other projects.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am not sure what ”having a 'test' extra that depends on pytest“ means.

Copy link
Owner

Choose a reason for hiding this comment

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

It's not important.

(It means having an extra_requires={'test': ['pytest']} in setup.py or whatever the equivalent is in pyproject.toml, and a [testenv] extras = test` in tox.ini.)

@jtojnar jtojnar force-pushed the types branch 3 times, most recently from aed4766 to 16584da Compare May 29, 2023 13:01
Not perfect: mypy will complain “Value of type "Style" is not indexable”
because `Theme.__getattr__()` accesses `self.ctlseq`, which is only defined
in the `AnsiTheme` subclass so it will consider it to be of `Style` type
as returned by `__getattr__()`, rather than the actual `Dict[str,str]`.
Get rid of `Theme.__getattr__` method since that is hard to statically check.
Use explicitly constructed static methods instead.
Also split the factory out of `Theme` into `ThemeFactory`.
@jtojnar
Copy link
Contributor Author

jtojnar commented May 30, 2023

I have dropped the pyproject.toml commit as we need to pull in types-setuptools anyway.

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.

2 participants