Repository: python/cpython
Pull request: gh-124022: Fix bug where class docstring is removed in interactive mode
Fixes #124022.

Class docstring is removed in interactive mode, but since the `first_instr` is not bumped to 1, this ends up being a `NOP` with the line number of the docstring.

<!--
Thanks for your contribution!
Please read this comment in its entirety. It's quite important.

# Pull Request title

It should be in the following format:

```
gh-NNNNN: Summary of the changes made
```

Where: gh-NNNNN refers to the GitHub issue number.

Most PRs will require an issue number. Trivial changes, like fixing a typo, do not need an issue.

# Backport Pull Request title

If this is a backport PR (PR made against branches other than `main`),
please ensure that the PR title is in the following format:

```
[X.Y] <title from the original PR> (GH-NNNN)
```

Where: [X.Y] is the branch name, e.g. [3.6].

GH-NNNN refers to the PR number from `main`.

-->


<!-- gh-issue-number: gh-124022 -->
* Issue: gh-124022
<!-- /gh-issue-number -->


Problem statement:
class docstring is removed in interactive mode 
```
>>> import dis
>>> src = """class C:
...     "docstring"
...     x = 42"""
>>> dis.dis(compile(src, "<string>", "single"))
  0           RESUME                   0

  1           LOAD_BUILD_CLASS
              PUSH_NULL
              LOAD_CONST               0 (<code object C at 0x10c3b4f40, file "<string>", line 1>)
              MAKE_FUNCTION
              LOAD_CONST               1 ('C')
              CALL                     2
              STORE_NAME               0 (C)
              RETURN_CONST             2 (None)

Disassembly of <code object C at 0x10c3b4f40, file "<string>", line 1>:
  1           RESUME                   0
              LOAD_NAME                0 (__name__)
              STORE_NAME               1 (__module__)
              LOAD_CONST               0 ('C')
              STORE_NAME               2 (__qualname__)
              LOAD_CONST               1 (1)
              STORE_NAME               3 (__firstlineno__)

  2           NOP                                             <--- line 2 is where the docstring is. This NOP shouldn't be here

  3           LOAD_CONST               2 (42)
              STORE_NAME               4 (x)
              LOAD_CONST               3 (())
              STORE_NAME               5 (__static_attributes__)
              RETURN_CONST             4 (None)

```

<!-- gh-linked-prs -->
### Linked PRs
* gh-124023
* gh-124052
* gh-124075
<!-- /gh-linked-prs -->

Hints from issue discussion:
Following discussion (see PR) the special handling for interactive mode is new in 3.14, and was inserted by accident. The fix will be to not remove docstrings in interactive mode (as was the case in 3.13).

Commit message: Fix bug where string literals in REPL got interpreted as docstrings

Patch to review:
diff --git a/Include/internal/pycore_compile.h b/Include/internal/pycore_compile.h
index 51db6fc608caf7..5359f2d650e2a2 100644
--- a/Include/internal/pycore_compile.h
+++ b/Include/internal/pycore_compile.h
@@ -172,7 +172,8 @@ int _PyCompile_AddDeferredAnnotaion(struct _PyCompiler *c, stmt_ty s);
 int _PyCodegen_AddReturnAtEnd(struct _PyCompiler *c, int addNone);
 int _PyCodegen_EnterAnonymousScope(struct _PyCompiler* c, mod_ty mod);
 int _PyCodegen_Expression(struct _PyCompiler *c, expr_ty e);
-int _PyCodegen_Body(struct _PyCompiler *c, _Py_SourceLocation loc, asdl_stmt_seq *stmts);
+int _PyCodegen_Body(struct _PyCompiler *c, _Py_SourceLocation loc, asdl_stmt_seq *stmts,
+                    bool is_interactive);
 
 /* Utility for a number of growing arrays used in the compiler */
 int _PyCompile_EnsureArrayLargeEnough(
diff --git a/Lib/test/test_compile.py b/Lib/test/test_compile.py
index f22761f0a3af9f..736eff35c1d5f2 100644
--- a/Lib/test/test_compile.py
+++ b/Lib/test/test_compile.py
@@ -902,6 +902,28 @@ def with_const_expression():
                 self.assertIsNone(ns['with_fstring'].__doc__)
                 self.assertIsNone(ns['with_const_expression'].__doc__)
 
+    @support.cpython_only
+    def test_docstring_interactive_mode(self):
+        srcs = [
+            """def with_docstring():
+                "docstring"
+            """,
+            """class with_docstring:
+                "docstring"
+            """,
+        ]
+
+        for opt in [0, 1, 2]:
+            for src in srcs:
+                with self.subTest(opt=opt, src=src):
+                    code = compile(textwrap.dedent(src), "<test>", "single", optimize=opt)
+                    ns = {}
+                    exec(code, ns)
+                    if opt < 2:
+                        self.assertEqual(ns['with_docstring'].__doc__, "docstring")
+                    else:
+                        self.assertIsNone(ns['with_docstring'].__doc__)
+
     @support.cpython_only
     def test_docstring_omitted(self):
         # See gh-115347
@@ -919,12 +941,13 @@ class C:
                 return h
         """)
         for opt in [-1, 0, 1, 2]:
-            with self.subTest(opt=opt):
-                code = compile(src, "<test>", "exec", optimize=opt)
-                output = io.StringIO()
-                with contextlib.redirect_stdout(output):
-                    dis.dis(code)
-                self.assertNotIn('NOP' , output.getvalue())
+            for mode in ["exec", "single"]:
+                with self.subTest(opt=opt, mode=mode):
+                    code = compile(src, "<test>", mode, optimize=opt)
+                    output = io.StringIO()
+                    with contextlib.redirect_stdout(output):
+                        dis.dis(code)
+                    self.assertNotIn('NOP', output.getvalue())
 
     def test_dont_merge_constants(self):
         # Issue #25843: compile() must not merge constants which are equal
diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2024-09-12-21-53-26.gh-issue-124022.fQzUiW.rst b/Misc/NEWS.d/next/Core_and_Builtins/2024-09-12-21-53-26.gh-issue-124022.fQzUiW.rst
new file mode 100644
index 00000000000000..90a77a5346d22b
--- /dev/null
+++ b/Misc/NEWS.d/next/Core_and_Builtins/2024-09-12-21-53-26.gh-issue-124022.fQzUiW.rst
@@ -0,0 +1 @@
+Fix bug where docstring is removed from classes in interactive mode.
diff --git a/Python/codegen.c b/Python/codegen.c
index 2ca5db1fc6ad34..ee83aa41c55c93 100644
--- a/Python/codegen.c
+++ b/Python/codegen.c
@@ -746,7 +746,7 @@ _PyCodegen_Expression(compiler *c, expr_ty e)
    and for annotations. */
 
 int
-_PyCodegen_Body(compiler *c, location loc, asdl_stmt_seq *stmts)
+_PyCodegen_Body(compiler *c, location loc, asdl_stmt_seq *stmts, bool is_interactive)
 {
     /* If from __future__ import annotations is active,
      * every annotated class and module should have __annotations__.
@@ -758,7 +758,10 @@ _PyCodegen_Body(compiler *c, location loc, asdl_stmt_seq *stmts)
         return SUCCESS;
     }
     Py_ssize_t first_instr = 0;
-    if (!IS_INTERACTIVE(c)) {
+    // If this is a top-level interactive statement, we don't want to
+    // extract a docstring, because then writing a string literal in the REPL
+    // would get interpreted as a docstring.
+    if (!is_interactive) {
         PyObject *docstring = _PyAST_GetDocString(stmts);
         if (docstring) {
             first_instr = 1;
@@ -1432,7 +1435,7 @@ codegen_class_body(compiler *c, stmt_ty s, int firstlineno)
         ADDOP_N_IN_SCOPE(c, loc, STORE_DEREF, &_Py_ID(__classdict__), cellvars);
     }
     /* compile the body proper */
-    RETURN_IF_ERROR_IN_SCOPE(c, _PyCodegen_Body(c, loc, s->v.ClassDef.body));
+    RETURN_IF_ERROR_IN_SCOPE(c, _PyCodegen_Body(c, loc, s->v.ClassDef.body, false));
     PyObject *static_attributes = _PyCompile_StaticAttributesAsTuple(c);
     if (static_attributes == NULL) {
         _PyCompile_ExitScope(c);
diff --git a/Python/compile.c b/Python/compile.c
index d54c320babc2b7..e1d2c30944d132 100644
--- a/Python/compile.c
+++ b/Python/compile.c
@@ -790,13 +790,13 @@ compiler_codegen(compiler *c, mod_ty mod)
     switch (mod->kind) {
     case Module_kind: {
         asdl_stmt_seq *stmts = mod->v.Module.body;
-        RETURN_IF_ERROR(_PyCodegen_Body(c, start_location(stmts), stmts));
+        RETURN_IF_ERROR(_PyCodegen_Body(c, start_location(stmts), stmts, false));
         break;
     }
     case Interactive_kind: {
         c->c_interactive = 1;
         asdl_stmt_seq *stmts = mod->v.Interactive.body;
-        RETURN_IF_ERROR(_PyCodegen_Body(c, start_location(stmts), stmts));
+        RETURN_IF_ERROR(_PyCodegen_Body(c, start_location(stmts), stmts, true));
         break;
     }
     case Expression_kind: {


Code review comments:
diff_hunk: @@ -758,7 +758,10 @@ _PyCodegen_Body(compiler *c, location loc, asdl_stmt_seq *stmts)
         return SUCCESS;
     }
     Py_ssize_t first_instr = 0;
-    if (!IS_INTERACTIVE(c)) {
+    // If this is a top-level interactive statement, we don't want to
+    // extract a docstring, because then writing a string literal in the REPL
+    // would get interpreted as a docstring.
+    if (!is_interactive) {
path: Python/codegen.c
text: @author:
```suggestion
    if (!is_interactive) {      /* A string literal typed on the REPL prompt is not a docstring */
```