An experiment in disallowing expression statements (or "unused values"), must_use/MustUse etc.
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.Actionis 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 returnAny(?)dict.pop()andlist.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.
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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