python / python/mypy

An experiment in disallowing expression statements (or "unused values"), must_use/MustUse etc.

Open
#8,951 12 comments 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
20.6k
Forks
3.3k
PR merge metrics
PR metrics pending

Description

Having worked with Rust for a while I grew fond of Rust telling me I'm not using some value returned from a function. After reading https://github.com/python/mypy/issues/6936 I started thinking about a possible approach to get something similar in Mypy, possibly by adding metadata to return types using PEP 593's Annotated:

_must_use_flag = object()
_T = TypeVar('_T')
MustUse = Annotated[_T, _must_use_flag]

# May perform a partial write
def write_bytes(data: bytes) -> MustUse[int]:
    # ...

But then I considered it some more and figured out that really the majority of return values from the functions I deal with should be handled one way or another, so it's possible the "must use" behavior should be the default. Well, not necessarily default-default, but gated with a Mypy flag possibly. I implemented a dirty version of this where an error is raised on every expression in statement context (not only function calls), you can find it in the following commit: https://github.com/jstasiak/mypy/commit/8e8667b8b7910a5b9284bd0cc9850acb4e181c21 (I'm skipping literal values reporting /because docstrings/, ellipsis (because it can't possibly be an error in this context) and -values).

I also added some logging code in the same branch so I can gather some stats and open discussion on this: https://github.com/jstasiak/mypy/commit/d685a010c26d7740ae0839b8ba84196e034c5c68

I ran mypy self-test with this and here are the results:

# The number of all expressions encountered
% wc -l expr_logger.txt 
  226248 expr_logger.txt

# The number of disallowed expression statements
% wc -l stmt_logger.txt 
     409 stmt_logger.txt

So ~0.18% of all expressions in Mypy are currently used in statement context. The counts of specific types of expressions are as follows:

% cat expr_logger.txt | cut -d ' ' -f 2 | sort | uniq -c | sort -r -n
92277 NameExpr
38158 MemberExpr
25766 CallExpr
18001 StrExpr
10924 IntExpr
8503 TempNode
5161 EllipsisExpr
4837 ComparisonExpr
4648 OpExpr
4048 TupleExpr
3502 IndexExpr
2723 ListExpr
2683 UnaryExpr
1896 SliceExpr
 478 ListComprehension
 404 GeneratorExpr
 367 DictExpr
 332 ConditionalExpr
 291 SuperExpr
 256 TypeAliasExpr
 240 SetExpr
 173 LambdaExpr
 169 YieldExpr
 148 TypeVarExpr
 101 CastExpr
  40 SetComprehension
  40 DictionaryComprehension
  26 FloatExpr
  22 NamedTupleExpr
  20 BytesExpr
  12 YieldFromExpr
   2 TypedDictExpr

The only expression statements in Mypy are call expressions, which isn't surprising:

% cat stmt_logger.txt | cut -d ' ' -f 2 | sort | uniq -c | sort -r -n
 409 CallExpr

I log the following data in those cases so we can dig deeper:

% head stmt_logger.txt                                               
EXPR CallExpr -> Any in /Users/user/projects/mypy/mypy/test/data.py:201:8
EXPR CallExpr (None) -> Any in /Users/user/projects/mypy/mypy/test/data.py:217:12
EXPR CallExpr (None) -> Any in /Users/user/projects/mypy/mypy/test/data.py:219:8
EXPR CallExpr (None) -> Any in /Users/user/projects/mypy/mypy/test/data.py:221:12
EXPR CallExpr (shutil.copytree) -> builtins.str in /Users/user/projects/mypy/mypy/test/data.py:232:16
EXPR CallExpr (None) -> builtins.int in /Users/user/projects/mypy/mypy/test/data.py:245:16
EXPR CallExpr (None) -> Any in /Users/user/projects/mypy/mypy/test/data.py:269:12
EXPR CallExpr (None) -> builtins.str* in /Users/user/projects/mypy/mypy/test/data.py:396:8
EXPR CallExpr (None) -> Any in /Users/user/projects/mypy/mypy/test/data.py:488:4
EXPR CallExpr (None) -> Any in /Users/user/projects/mypy/mypy/test/data.py:491:4

Numbers by the function called:

% cat stmt_logger.txt | sed 's/.*(\(.*\)).*/\1/' | sort | uniq -c | sort -r -n
 382 None
   3 shutil.copyfile
   3 mypy.stubtest.test_stubs
   2 unittest.main
   2 mypy.test.testdaemon.run_cmd
   2 mypy.dmypy.client.get_status
   2 mypy.dmypy.client.check_status
   1 subprocess.check_output
   1 shutil.copytree
   1 shutil.copy
   1 os.umask
   1 mypy.test.testparse.skip
   1 mypy.parse.parse
   1 mypy.build.load_graph
   1 mypy.build.find_module_and_diagnose
   1 mypy.build.build
   1 gc.collect
   1 func
   1 builtins.list
   1 EXPR CallExpr -> Any in /Users/user/projects/mypy/mypy/test/data.py:201:8

This is clearly wrong because I fail to extract the full names of the majority of the callees here but it's not obvious to me why.

Numbers by the type returned (ignore the string-cutting artifacts in complex types containing whitespace, I just use cut here for simplicity):

% cat stmt_logger.txt | cut -d ' ' -f 5 | sort | uniq -c | sort -r -n
 120 argparse.Action
  73 builtins.int
  41 builtins.bool
  38 Any
  24 mypy.types.Type
  16 builtins.int*
   6 builtins.list[builtins.str]
   6 Tuple[builtins.int,
   5 builtins.str
   4 sqlite3.dbapi2.Cursor
   4 mypy.types.Type*
   4 builtins.set*[builtins.str]
   4 builtins.bool*
   4 Union[Literal['C'],
   3 mypy.types.Instance*
   3 mypy.nodes.FuncItem*
   3 builtins.dict*[builtins.str,
   3 Union[mypy.types.Type,
   3 Tuple[mypy.types.Type,
   3 Tuple[builtins.str,
   2 unittest.TestProgram
   2 typing.AbstractSet[builtins.str]
   2 os.stat_result
   2 mypy.types.TypeVarDef
   2 mypy.plugin.Plugin*
   2 mypy.nodes.IndexExpr*
   2 mypy.binder.Frame
   2 builtins.bytes
   2 Union[mypy.nodes.TypeInfo,
   2 Union[mypy.nodes.SymbolTable,
   1 typed_ast.ast3.AST*
   1 mypy.types.TypeAliasType*
   1 mypy.split_namespace.SplitNamespace*
   1 mypy.report.AbstractReporter
   1 mypy.nodes.TypeInfo*
   1 mypy.nodes.MypyFile
   1 mypy.nodes.Expression*
   1 mypy.moduleinspect.ModuleProperties
   1 mypy.build.BuildResult
   1 in
   1 builtins.str*
   1 builtins.list[mypy.types.Type]
   1 builtins.list[mypy.types.TypeVarDef]
   1 builtins.list[Union[mypy.build.BuildResult,
   1 builtins.list*[builtins.str]
   1 builtins.dict[builtins.str,
   1 argparse.Namespace*
   1 Union[mypy.types.Instance,
   1 Union[builtins.str,
   1 Union[builtins.int,
   1 Tuple[mypy.types.TypeAliasType*,
   1 Tuple[mypy.types.Instance*,

I can't comment on Mypy-specific types, but as for some others:

  • argparse.Action is returned by all those argparse calls:
mypy/dmypy/client.py:57: error: Unused expression of type 'argparse.Action'  [misc]
    p.add_argument('--timeout', metavar='TIMEOUT', type=int,
    ^
mypy/dmypy/client.py:59: error: Unused expression of type 'argparse.Action'  [misc]
    p.add_argument('flags', metavar='FLAG', nargs='*', type=str,
    ^
mypy/dmypy/client.py:63: error: Unused expression of type 'argparse.Action'  [misc]
    p.add_argument('-v', '--verbose', action='store_true', help="Print detailed status")
    ^
mypy/dmypy/client.py:64: error: Unused expression of type 'argparse.Action'  [misc]
    p.add_argument('--fswatcher-dump-file', help="Collect information about the current file state")
    ^
mypy/dmypy/client.py:72: error: Unused expression of type 'argparse.Action'  [misc]
    p.add_argument('-v', '--verbose', action='store_true', help="Print detailed status")
    ^
mypy/dmypy/client.py:73: error: Unused expression of type 'argparse.Action'  [misc]
    p.add_argument('-q', '--quiet', action='store_true', help=argparse.SUPPRESS)  # Deprecated
    ^

and since there are a lot of arguments there's a lot of those false-positives.

  • stderror.write(), file.write() and other similar calls:
mypy/main.py:78: error: Unused expression of type 'builtins.int'  [misc]
                    f.write(msg + '\n')
                    ^
mypy/main.py:117: error: Unused expression of type 'builtins.int'  [misc]
                    stdout.write(formatter.format_error(n_errors, n_files, len(sources),
                    ^
mypy/main.py:120: error: Unused expression of type 'builtins.int'  [misc]
                stdout.write(formatter.format_success(len(sources),
                ^

Those are almost entirely false-positives – all cases I looked at here were text tiles and TextIO.write() never performs partial writes so its return value is irrelevant.

  • __init__() methods return Any (?)
  • dict.pop() and list.pop() return values but they're commonly used to just remove values from the collections

I'm attaching a full report of running modified Mypy on itself for your consideration. After looking at the output of this experiment I'm tempted to claim that instead of having an opt-in way to force use of values it makes more sense to have a flag to make it default and also provide an opt-out mechanism.

report.txt

Edit: by an opt-out mechanism I mean something like:

_may_not_use_flag = object()
_T = TypeVar('_T')
MayNotUse = Annotated[_T, _may_not_use_flag]

# Always performs full write, the return value is not important unless one wants to
# count the bytes written
class TextIO:
    def write(self, data: str) -> MayNotUse[int]:
        # ...

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reviewing the linked experimental commits and report, then inspect the expression-statement diagnostics shown in mypy/main.py, mypy/dmypy/client.py, and test/data.py. The issue does not define a settled design or acceptance criteria; completion would require establishing the must-use and opt-out semantics and verifying the intended diagnostics.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
devtools, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.