Repository: spyder-ide/spyder
Pull request: PR: Create magics for run|debug file|cell (IPython console)
## Description of Changes

- Depends on https://github.com/spyder-ide/spyder-kernels/pull/447

### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

Fixes #20630


### Affirmation

By submitting this Pull Request or typing my (user)name below,
I affirm the [Developer Certificate of Origin](https://developercertificate.org)
with respect to all commits and content included in this PR,
and understand I am releasing the same under Spyder's MIT (Expat) license.

<!--- TYPE YOUR USER/NAME AFTER THE FOLLOWING: --->
I certify the above statement is true and correct:

<!--- Thanks for your help making Spyder better for everyone! --->


Problem statement:
Proposition: Turn runfile / runcell to IPython magics
The functions:
 - `runfile`
 - `runcell`
 - `debugfile`
 - `debugcell`
 
Are now added to the builtins. This is a bit strange because you wouldn't want to be able to call them from deep inside a library.
I suggest replacing them with magic instead. The calling convention would be a bit different,  but it would be cleat that these are no ordinary functions (e.g. you can't really pass any object as an argument, only strings, which is what magics are for)

Hints from issue discussion:
Also this greatly simplifies the handling of the namespace and removes the differences between debugcell / debug selection with the debug magic
Do you think we _really_ need this? It's a huge change on the way Spyder has been working since probably it was created (i.e. more than 10 years ago) and I'm sure it would impact all written and video materials by teachers about running files in Spyder.

And the way you described it:

> Also this greatly simplifies the handling of the namespace and removes the differences between debugcell / debug selection with the debug magic

sounds like a code convenience that doesn't justify breaking everybody's workflows.

I understand that `runfile/debugfile` shouldn't be exposed as builtin functions. But it took us a long time to only support IPython (before we had a Python console too), so we couldn't use magics for them. And, at this point, I'd say this is not something we should be considering to do, unless there are _very_ strong reasons for it.
I mostly dislike the idea of someone adding a random `runcell()` in a library and ending up with code that would only run in spyder and not in python, without the issue being clear to the user. If `runcell` was a magic instead, this would not be possible, because Magics are only usable in the console or in `.ipy` files (well spyder accepts them in the main file but complains about it)

Regarding users workflow, I expect most people to press the buttons on the interface or use keyboard shortcuts, so they would only notice the console printing `%runfile "/path/to/file.py"` instead of `runfile("/path/to/file.py")`, which is not a huge change. What I personnaly do is to press up + enter to rerun a file in the console, but that would work as well with magics.

But I will concede that these are not very strong reasons.
@pscobey, since you mentioned you're teaching Python with Spyder, I'd like to ask you about this proposed change (hope that's ok). So, instead of using a function to run files or cells (i.e. `runfile(...)` or `runcell(...)`), we'd like to use IPython magics instead (i.e. `%runfile`/`%runcell`) for Spyder 6.

As a professor, would you have problems with that change? I mean, perhaps you have some written material or videos explaining students what `runfile` is, which you'd have to modify. Or do you foresee any significant problems for students by using magics?

Thanks for your help!

Hi Carlos,

If I have several short programs in a single
file and I'm looking at that file in Spyder, with
each program in a separate cell, it is very
convenient to be able to run a program/cell
with Ctrl + Enter, or a couple of programs one
after the other with Shift + Enter. That saves a
lot of time as compared to having each of the
programs in its own file, so I would hate to lose
those shortcuts and that facility ... 🙂

Hope that makes sense ...

Porter

Porter Scobey
Department of Mathematics and Computing Science
Saint Mary's University
923 Robie Street
Halifax, NS, Canada
B3H 3C3
Tel: 902-420-5790
Web: http://cs.smu.ca/~porter
________________________________
From: Carlos Cordoba ***@***.***>
Sent: Sunday, March 19, 2023 2:58 PM
To: spyder-ide/spyder ***@***.***>
Cc: Porter Scobey ***@***.***>; Mention ***@***.***>
Subject: Re: [spyder-ide/spyder] Proposition: turn runfile / runcell to magic (Issue #20630)


@pscobey<https://can01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fpscobey&data=05%7C01%7Cporter.scobey%40smu.ca%7C00d9ff71b9a945267fd308db28a38be8%7C060b02ae57754360abbae2e29cca6627%7C1%7C0%7C638148455109550892%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=foq%2Ff8d28LkIIPMKF3g1vxlgx3NOiJpYTOx6nzx4JrY%3D&reserved=0>, since you mentioned you're teaching Python with Spyder to newbies, I'd like to ask you about this proposed change (hope that's ok). So, instead of using a function to run files or cells (i.e. runfile(...) or runcell(...)), we'd like to use IPython magics instead (i.e. %runfile/%runcell) for Spyder 6.

As a professor, would you have problems with that change? I mean, perhaps you have some written material or videos explaining students what runfile is, which you'd have to modify. Or do you foresee any significant problems for students by using magics?

Thanks for your help!

—
Reply to this email directly, view it on GitHub<https://can01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fspyder-ide%2Fspyder%2Fissues%2F20630%23issuecomment-1475346993&data=05%7C01%7Cporter.scobey%40smu.ca%7C00d9ff71b9a945267fd308db28a38be8%7C060b02ae57754360abbae2e29cca6627%7C1%7C0%7C638148455109550892%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=bHm%2BmDNpkE0nrp4Hi0nLvhQ%2FiLoSxHTSQwLbjZI1UiY%3D&reserved=0>, or unsubscribe<https://can01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fnotifications%2Funsubscribe-auth%2FACBKD52LQ5RAAQEM2WS2DYTW45CMJANCNFSM6AAAAAAVPM6WOI&data=05%7C01%7Cporter.scobey%40smu.ca%7C00d9ff71b9a945267fd308db28a38be8%7C060b02ae57754360abbae2e29cca6627%7C1%7C0%7C638148455109550892%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=S2RLLKbaueD6LrZYLjwtArZZvedpdLPWC5DQSgjOdto%3D&reserved=0>.
You are receiving this because you were mentioned.Message ID: ***@***.***>

Hi @pscobey ,

Many thanks for your input! To clarify, all the keyboard shortcuts and buttons would stay the same. The modification is that when you press Ctrl + Enter, instead of seeing in the console:

`runcell(0, "script.py")`

You would see:

`%runcell -i 0 "script.py"`

The reason for the change is that we want to make clear that `runcell` is not a python function, but instead a special "magic" command spyder uses to run code. We are however concerned that this modification could be confusing for students.

For example, a tutorial video of spyder could explain that to run a cell, you need to press Ctrl + Enter. If the video was recorded with spyder 5, that would cause the `runcell(0, "script.py")` message to be printed to the console. If the student is using spyder 6 with this modification, he would instead see `%runcell -i 0 "script.py"`.

So the question is: is it worth it to make it clear that `runcell` is not a python function by slightly modifying the way it is printed to the console? The risk would be that this graphical change would of course not be present in existing spyder video tutorials.

Many thanks and have a good day

That's great ... thanks for the clarification!

I'm just speaking for myself, of course, but
I suspect that what actually appears in the
Spyder console to run the code when the
keyboard shortcut is pressed is not even
"noticed" most of the time ... the students
(and I 🙂) are more interested in what the
code has produced than they are in the
command that has run the code (in the
Spyder context).

However, if they are running a file in an iPython
window outside Spyder, then of course they need
to know (or at least should be told) the difference,
so having "magic consistency" inside and outside
Spyder would be a good thing, and thus argue for
the change you are proposing.

I hope that makes sense ... 🙂

Thanks again,
Porter

Porter Scobey
Department of Mathematics and Computing Science
Saint Mary's University
923 Robie Street
Halifax, NS, Canada
B3H 3C3
Tel: 902-420-5790
Web: http://cs.smu.ca/~porter
________________________________
From: Quentin Peter ***@***.***>
Sent: Monday, March 20, 2023 3:57 AM
To: spyder-ide/spyder ***@***.***>
Cc: Porter Scobey ***@***.***>; Mention ***@***.***>
Subject: Re: [spyder-ide/spyder] Proposition: turn runfile / runcell to magic (Issue #20630)


Hi @pscobey<https://can01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fpscobey&data=05%7C01%7Cporter.scobey%40smu.ca%7C577e1da667994a03cb5508db2910641e%7C060b02ae57754360abbae2e29cca6627%7C1%7C0%7C638148922598437761%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=zNffFhnaTt8qEqUFuGqF1iYDkk1GdYtgXFkqU23AK5w%3D&reserved=0> ,

Many thanks for your input! To clarify, all the keyboard shortcuts and buttons would stay the same. The modification is that when you press Ctrl + Enter, instead of seeing in the console:

runcell(0, "script.py")

You would see:

%runcell -i 0 "script.py"

The reason for the change is that we want to make clear that runcell is not a python function, but instead a special "magic" command spyder uses to run code. We are however concerned that this modification could be confusing for students.

For example, a tutorial video of spyder could explain that to run a cell, you need to press Ctrl + Enter. If the video was recorded with spyder 5, that would cause the runcell(0, "script.py") message to be printed to the console. If the student is using spyder 6 with this modification, he would instead see %runcell -i 0 "script.py".

So the question is: is it worth it to make it clear that runcell is not a python function by slightly modifying the way it is printed to the console? The risk would be that this graphical change would of course not be present in existing spyder video tutorials.

Many thanks and have a good day

—
Reply to this email directly, view it on GitHub<https://can01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fspyder-ide%2Fspyder%2Fissues%2F20630%23issuecomment-1475712790&data=05%7C01%7Cporter.scobey%40smu.ca%7C577e1da667994a03cb5508db2910641e%7C060b02ae57754360abbae2e29cca6627%7C1%7C0%7C638148922598437761%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=qTsge2HmNemWvPCDABlJIImvklSbKIpvclJ9rlPnRPs%3D&reserved=0>, or unsubscribe<https://can01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fnotifications%2Funsubscribe-auth%2FACBKD5YVVJGTW3VV4LDY4MTW475WDANCNFSM6AAAAAAVPM6WOI&data=05%7C01%7Cporter.scobey%40smu.ca%7C577e1da667994a03cb5508db2910641e%7C060b02ae57754360abbae2e29cca6627%7C1%7C0%7C638148922598437761%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=XIDEI4DEw1EBOUnkXQ3RzYky4CO2lR1w5MJtqMUWwyE%3D&reserved=0>.
You are receiving this because you were mentioned.Message ID: ***@***.***>

Thanks for your great feedback @pscobey! I was hesitant about doing this change but what you said made me realize that it'll make the way code is executed in and out of Spyder more consistent.

Commit message: git subrepo clone --branch=magics_runfile --force https://github.com/impact27/spyder-kernels.git external-deps/spyder-kernels

subrepo:
  subdir:   "external-deps/spyder-kernels"
  merged:   "8208bcdbb"
upstream:
  origin:   "https://github.com/impact27/spyder-kernels.git"
  branch:   "magics_runfile"
  commit:   "8208bcdbb"
git-subrepo:
  version:  "0.4.5"
  origin:   "https://github.com/ingydotnet/git-subrepo"
  commit:   "aa416e4"

Patch to review:
diff --git a/external-deps/spyder-kernels/.gitrepo b/external-deps/spyder-kernels/.gitrepo
index c53adcbee4c..41eb9ad58a5 100644
--- a/external-deps/spyder-kernels/.gitrepo
+++ b/external-deps/spyder-kernels/.gitrepo
@@ -5,8 +5,8 @@
 ;
 [subrepo]
 	remote = https://github.com/spyder-ide/spyder-kernels.git
-	branch = master
-	commit = 1deda5137a20980a3e6365ac391f66e3418423d7
-	parent = 95229163778ff85a29d650dcd2dc408f7c3f9b06
+	branch = magics_runfile
+	commit = 8208bcdbb321402168935d266df63cf5354478b8
+	parent = da9e4288e054530d224c79bcfe7d4982fc99d254
 	method = merge
 	cmdver = 0.4.5
diff --git a/external-deps/spyder-kernels/spyder_kernels/console/shell.py b/external-deps/spyder-kernels/spyder_kernels/console/shell.py
index fbe4bd1e318..c859f985c26 100644
--- a/external-deps/spyder-kernels/spyder_kernels/console/shell.py
+++ b/external-deps/spyder-kernels/spyder_kernels/console/shell.py
@@ -12,7 +12,6 @@
 
 # Standard library imports
 import bdb
-import builtins
 import logging
 import os
 import signal
@@ -68,13 +67,8 @@ def __init__(self, *args, **kwargs):
     def init_magics(self):
         """Init magics"""
         super().init_magics()
-        code_runner = SpyderCodeRunner(shell=self)
-        self.register_magics(code_runner)
-        builtins.runfile = code_runner.runfile
-        builtins.debugfile = code_runner.debugfile
-        builtins.runcell = code_runner.runcell
-        builtins.debugcell = code_runner.debugcell
-        
+        self.register_magics(SpyderCodeRunner)
+
     def ask_exit(self):
         """Engage the exit actions."""
         if self.active_eventloop not in [None, "inline"]:
diff --git a/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py b/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py
index 1252bbd9416..ab8f665ae54 100644
--- a/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py
+++ b/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py
@@ -559,7 +559,7 @@ def f(x):
 
         # Run code
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT)
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT)
 
         # Verify that the `result` variable is defined
         client.inspect('result')
@@ -603,7 +603,7 @@ def myFunc(i):
 
         # Run code
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT)
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT)
 
         # Verify that the `result` variable is defined
         client.inspect('result')
@@ -645,10 +645,10 @@ def test_dask_multiprocessing(tmpdir):
 
         # Run code two times
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT)
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT)
 
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT)
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT)
 
         # Verify that the `x` variable is defined
         client.inspect('x')
@@ -687,8 +687,8 @@ def test_runfile(tmpdir):
         u.write(code)
 
         # Run code file `d` to define `result` even after an error
-        client.execute_interactive("runfile(r'{}', current_namespace=False)"
-                                  .format(str(d)), timeout=TIMEOUT)
+        client.execute_interactive(
+            "%runfile {}".format(repr(str(d))), timeout=TIMEOUT)
 
         # Verify that `result` is defined in the current namespace
         client.inspect('result')
@@ -699,8 +699,8 @@ def test_runfile(tmpdir):
         assert content['found']
 
         # Run code file `u` without current namespace
-        client.execute_interactive("runfile(r'{}', current_namespace=False)"
-                                  .format(str(u)), timeout=TIMEOUT)
+        client.execute_interactive(
+            "%runfile {}".format(repr(str(u))), timeout=TIMEOUT)
 
         # Verify that the variable `result2` is defined
         client.inspect('result2')
@@ -711,8 +711,8 @@ def test_runfile(tmpdir):
         assert content['found']
 
         # Run code file `u` with current namespace
-        msg = client.execute_interactive("runfile(r'{}', current_namespace=True)"
-                                        .format(str(u)), timeout=TIMEOUT)
+        msg = client.execute_interactive("%runfile {} --current-namespace"
+                                        .format(repr(str(u))), timeout=TIMEOUT)
         content = msg['content']
 
         # Verify that the variable `result3` is defined
@@ -835,7 +835,7 @@ def test_turtle_launch(tmpdir):
 
         # Run code
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT)
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT)
 
         # Verify that the `tess` variable is defined
         client.inspect('tess')
@@ -853,7 +853,7 @@ def test_turtle_launch(tmpdir):
 
         # Run code again
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT)
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT)
 
         # Verify that the `a` variable is defined
         client.inspect('a')
@@ -1228,13 +1228,13 @@ def check_found(msg):
                     found = True
 
         # Run code in current namespace
-        client.execute_interactive("runfile(r'{}', current_namespace=True)".format(
-            str(p)), timeout=TIMEOUT, output_hook=check_found)
+        client.execute_interactive("%runfile {} --current-namespace".format(
+            repr(str(p))), timeout=TIMEOUT, output_hook=check_found)
         assert not found
 
         # Run code in empty namespace
         client.execute_interactive(
-            "runfile(r'{}')".format(str(p)), timeout=TIMEOUT,
+            "%runfile {}".format(repr(str(p))), timeout=TIMEOUT,
             output_hook=check_found)
 
         assert found
@@ -1254,7 +1254,7 @@ def test_debug_namespace(tmpdir):
         d.write('def func():\n    bb = "hello"\n    breakpoint()\nfunc()')
 
         # Run code file `d`
-        msg_id = client.execute("runfile(r'{}')".format(str(d)))
+        msg_id = client.execute("%runfile {}".format(repr(str(d))))
 
         # make sure that 'bb' returns 'hello'
         client.get_stdin_msg(timeout=TIMEOUT)
diff --git a/external-deps/spyder-kernels/spyder_kernels/customize/code_runner.py b/external-deps/spyder-kernels/spyder_kernels/customize/code_runner.py
index 71d40b84415..630a2034c31 100644
--- a/external-deps/spyder-kernels/spyder_kernels/customize/code_runner.py
+++ b/external-deps/spyder-kernels/spyder_kernels/customize/code_runner.py
@@ -29,9 +29,12 @@
     leading_empty_lines,
 )
 from IPython.core.magic import (
+    needs_local_scope,
     magics_class,
     Magics,
+    line_magic,
 )
+from IPython.core import magic_arguments
 
 # Local imports
 from spyder_kernels.comms.frontendcomm import frontend_request
@@ -45,6 +48,93 @@
 logger = logging.getLogger(__name__)
 
 
+def runfile_arguments(func):
+    """Decorator to add runfile magic arguments to magic."""
+    decorators = [
+        magic_arguments.magic_arguments(),
+        magic_arguments.argument(
+            "filename",
+            help="""
+            Filename to run
+            """,
+        ),
+        magic_arguments.argument(
+            "--args",
+            help="""
+            Command line arguments (string)
+            """,
+        ),
+        magic_arguments.argument(
+            "--wdir",
+            const=True,
+            nargs="?",
+            help="""
+            Working directory
+            """,
+        ),
+        magic_arguments.argument(
+            "--post-mortem",
+            action="store_true",
+            help="""
+            Enter post-mortem mode on errors
+            """,
+        ),
+        magic_arguments.argument(
+            "--current-namespace",
+            action="store_true",
+            help="""
+            Use current namespace
+            """,
+        ),
+        magic_arguments.argument(
+            "--namespace",
+            help="""
+            Namespace to run the file in
+            """,
+        )
+        ]
+    for dec in reversed(decorators):
+        func = dec(func)
+    return func
+
+
+def runcell_arguments(func):
+    """Decorator to add runcell magic arguments to magic."""
+    decorators = [
+        magic_arguments.magic_arguments(),
+        magic_arguments.argument(
+            "--name", "-n",
+            help="""
+            Cell name.
+            """,
+        ),
+        magic_arguments.argument(
+            "--index", "-i",
+            help="""
+            Cell index.
+            """,
+        ),
+        magic_arguments.argument(
+            "filename",
+            nargs="?",
+            help="""
+            Filename
+            """,
+        ),
+        magic_arguments.argument(
+            "--post-mortem",
+            action="store_true",
+            default=False,
+            help="""
+            Enter post-mortem mode on errors
+            """,
+        )
+        ]
+    for dec in reversed(decorators):
+        func = dec(func)
+    return func
+
+
 @magics_class
 class SpyderCodeRunner(Magics):
     """
@@ -58,94 +148,84 @@ def __init__(self, *args, **kwargs):
         )
         super().__init__(*args, **kwargs)
 
-    def runfile(self, filename=None, args=None, wdir=None, namespace=None,
-                post_mortem=False, current_namespace=False):
+    @runfile_arguments
+    @needs_local_scope
+    @line_magic
+    def runfile(self, line, local_ns=None):
         """
         Run a file.
         """
-        if namespace is None:
-            namespace = self.shell.user_ns
-            local_ns = self.shell.get_local_scope(1)
-        else:
-            local_ns = None
-            current_namespace = True
-        if filename is None:
-            filename = self._get_current_file_name()
-        canonic_filename = canonic(filename)
+        args, local_ns = self._parse_runfile_argstring(
+            self.runfile, line, local_ns)
 
         return self._exec_file(
-            filename=filename,
-            canonic_filename=canonic_filename,
-            args=args,
-            wdir=wdir,
-            post_mortem=post_mortem,
-            current_namespace=current_namespace,
-            context_globals=namespace,
+            filename=args.filename,
+            canonic_filename=args.canonic_filename,
+            args=args.args,
+            wdir=args.wdir,
+            post_mortem=args.post_mortem,
+            current_namespace=args.current_namespace,
+            context_globals=args.namespace,
             context_locals=local_ns,
         )
 
-    def debugfile(self, filename=None, args=None, wdir=None, namespace=None,
-                  post_mortem=False, current_namespace=False):
+    @runfile_arguments
+    @needs_local_scope
+    @line_magic
+    def debugfile(self, line, local_ns=None):
         """
         Debug a file.
         """
-        if namespace is None:
-            namespace = self.shell.user_ns
-            local_ns = self.shell.get_local_scope(1)
-        else:
-            local_ns = None
-            current_namespace = True
-        if filename is None:
-            filename = self._get_current_file_name()
-        canonic_filename = canonic(filename)
+        args, local_ns = self._parse_runfile_argstring(
+            self.debugfile, line, local_ns)
 
-        with self._debugger_exec(canonic_filename, True) as debug_exec:
+        with self._debugger_exec(args.canonic_filename, True) as debug_exec:
             self._exec_file(
-                filename=filename,
-                canonic_filename=canonic_filename,
-                args=args,
-                wdir=wdir,
-                current_namespace=current_namespace,
+                filename=args.filename,
+                canonic_filename=args.canonic_filename,
+                args=args.args,
+                wdir=args.wdir,
+                current_namespace=args.current_namespace,
                 exec_fun=debug_exec,
-                post_mortem=post_mortem,
-                context_globals=namespace,
+                post_mortem=args.post_mortem,
+                context_globals=args.namespace,
                 context_locals=local_ns,
             )
 
-    def runcell(self, cellname, filename=None, post_mortem=False):
+    @runcell_arguments
+    @needs_local_scope
+    @line_magic
+    def runcell(self, line, local_ns=None):
         """
         Run a code cell from an editor.
         """
-        local_ns = self.shell.get_local_scope(1)
-        if filename is None:
-            filename = self._get_current_file_name()
-        canonic_filename = canonic(filename)
+        args = self._parse_runcell_argstring(self.runcell, line)
 
         return self._exec_cell(
-            cell_id=cellname,
-            filename=filename,
-            canonic_filename=canonic_filename,
-            post_mortem=post_mortem,
+            cell_id=args.cell_id,
+            filename=args.filename,
+            canonic_filename=args.canonic_filename,
+            post_mortem=args.post_mortem,
             context_globals=self.shell.user_ns,
             context_locals=local_ns,
         )
 
-    def debugcell(self, cellname, filename=None, post_mortem=False):
+    @runcell_arguments
+    @needs_local_scope
+    @line_magic
+    def debugcell(self, line, local_ns=None):
         """
         Debug a code cell from an editor.
         """
-        local_ns = self.shell.get_local_scope(1)
-        if filename is None:
-            filename = self._get_current_file_name()
-        canonic_filename = canonic(filename)
+        args = self._parse_runcell_argstring(self.debugcell, line)
 
-        with self._debugger_exec(canonic_filename, False) as debug_exec:
+        with self._debugger_exec(args.canonic_filename, False) as debug_exec:
             return self._exec_cell(
-                cell_id=cellname,
-                filename=filename,
-                canonic_filename=canonic_filename,
+                cell_id=args.cell_id,
+                filename=args.filename,
+                canonic_filename=args.canonic_filename,
                 exec_fun=debug_exec,
-                post_mortem=post_mortem,
+                post_mortem=args.post_mortem,
                 context_globals=self.shell.user_ns,
                 context_locals=local_ns,
             )
@@ -487,3 +567,45 @@ def _post_mortem_excepthook(self, type, value, tb):
             # wait for stdout to print
             time.sleep(0.1)
             p.interaction(frame, tb)
+    
+    def _parse_argstring(self, magic_func, argstring):
+        """
+        Parse a string of arguments for a magic function.
+
+        This is needed because magic_arguments.parse_argstring does
+        platform-dependent things with quotes and backslashes. For
+        example, on Windows, strings are removed and backslashes are
+        escaped.
+        """
+        argv = shlex.split(argstring)
+        args = magic_func.parser.parse_args(argv)
+        if args.filename is None:
+            args.filename = self._get_current_file_name()
+        args.canonic_filename = canonic(args.filename)
+        return args
+    
+    def _parse_runfile_argstring(self, magic_func, argstring, local_ns):
+        """Parse an args string for runfile and debugfile."""
+        args = self._parse_argstring(magic_func, argstring)
+        if args.namespace is None:
+            args.namespace = self.shell.user_ns
+        else:
+            if local_ns is not None and args.namespace in local_ns:
+                args.namespace = local_ns[args.namespace]
+            elif args.namespace in self.shell.user_ns:
+                args.namespace = self.shell.user_ns[args.namespace]
+            else:
+                raise NameError(
+                    f"name '{args.namespace}' is not defined"
+                )
+            local_ns = None
+            args.current_namespace = True
+        return args, local_ns
+
+    def _parse_runcell_argstring(self, magic_func, argstring):
+        """Parse an args string for runcell and debugcell."""
+        args = self._parse_argstring(magic_func, argstring)
+        args.cell_id = args.name
+        if args.cell_id is None:
+            args.cell_id = int(args.index)
+        return args
diff --git a/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py b/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py
index 43f4e3284bd..f758cf27711 100755
--- a/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py
+++ b/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py
@@ -112,7 +112,10 @@ def __exit__(self, exc_type, exc_val, exc_tb):
             self.ns_globals.pop('__file__')
 
         if not self.current_namespace:
-            self.context_globals.update(self.ns_globals)
+            if self.context_locals is not None:
+                self.context_locals.update(self.ns_globals)
+            else:
+                self.context_globals.update(self.ns_globals)
 
         if self._previous_main:
             sys.modules['__main__'] = self._previous_main
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
index 58c76c341ce..0beacd1862a 100644
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -1612,7 +1612,7 @@ def test_run_code(main_window, qtbot, tmpdir):
         shell.execute('%reset -f')
 
     with qtbot.waitSignal(shell.executed):
-        shell.execute('runcell(0)')
+        shell.execute('%runcell -i 0')
 
     # Verify result
     assert shell.get_value('a') == 10
@@ -2783,7 +2783,7 @@ def test_save_on_runfile(main_window, qtbot):
     qtbot.keyClicks(code_editor, 'test_var = 123', delay=100)
     filename = code_editor.filename
     with qtbot.waitSignal(shell.sig_prompt_ready):
-        shell.execute('runfile("{}")'.format(remove_backslashes(filename)))
+        shell.execute('%runfile {}'.format(repr(remove_backslashes(filename))))
 
     assert shell.get_value('test_var') == 123
     main_window.editor.close_file()
@@ -3314,7 +3314,7 @@ def test_runcell(main_window, qtbot, tmpdir, debug):
         function = 'runcell'
     # Execute runcell
     with qtbot.waitSignal(shell.executed):
-        shell.execute(function + u"(0, r'{}')".format(to_text_string(p)))
+        shell.execute("%{} -i 0 {}".format(function, repr(to_text_string(p))))
 
     if debug:
         # Reach the 'name' input
@@ -3352,7 +3352,7 @@ def test_runcell_leading_indent(main_window, qtbot, tmpdir):
 
     # Execute runcell
     with qtbot.waitSignal(shell.executed):
-        shell.execute("runcell(1, r'{}')".format(to_text_string(p)))
+        shell.execute("%runcell -i 1 {}".format(repr(to_text_string(p))))
 
     assert "1234" in shell._control.toPlainText()
     assert "This is not valid Python code" not in shell._control.toPlainText()
@@ -3550,17 +3550,17 @@ def test_runcell_edge_cases(main_window, qtbot, tmpdir):
     with qtbot.waitSignal(shell.executed):
         qtbot.mouseClick(main_window.run_cell_and_advance_button,
                          Qt.LeftButton)
-    qtbot.waitUntil(lambda: 'runcell(0' in shell._control.toPlainText(),
+    qtbot.waitUntil(lambda: '%runcell -i 0' in shell._control.toPlainText(),
                     timeout=SHELL_TIMEOUT)
-    assert 'runcell(0' in shell._control.toPlainText()
+    assert '%runcell -i 0' in shell._control.toPlainText()
     assert 'cell is empty' not in shell._control.toPlainText()
 
     with qtbot.waitSignal(shell.executed):
         qtbot.mouseClick(main_window.run_cell_and_advance_button,
                          Qt.LeftButton)
-    qtbot.waitUntil(lambda: 'runcell(1' in shell._control.toPlainText(),
+    qtbot.waitUntil(lambda: '%runcell -i 1' in shell._control.toPlainText(),
                     timeout=SHELL_TIMEOUT)
-    assert 'runcell(1' in shell._control.toPlainText()
+    assert '%runcell -i 1' in shell._control.toPlainText()
     assert 'Error' not in shell._control.toPlainText()
     assert 'cell is empty' in shell._control.toPlainText()
 
@@ -3740,8 +3740,8 @@ def test_pdb_key_leak(main_window, qtbot, tmpdir):
 
     # Run tmp2 and get an error
     with qtbot.waitSignal(shell.executed):
-        shell.execute('runfile("' + str(test_file2).replace("\\", "/") +
-                      '", wdir="' + str(folder).replace("\\", "/") + '")')
+        shell.execute('%runfile ' + repr(str(test_file2).replace("\\", "/")) +
+                      ' --wdir ' + repr(str(folder).replace("\\", "/")))
     assert '1/0' in control.toPlainText()
 
     # Replace QApplication.processEvents to make sure it is not called
@@ -3803,8 +3803,8 @@ def test_pdb_step(main_window, qtbot, tmpdir, where):
 
     # Run tmp2 and get an error
     with qtbot.waitSignal(shell.executed):
-        shell.execute('runfile("' + str(test_file2).replace("\\", "/") +
-                      '", wdir="' + str(folder).replace("\\", "/") + '")')
+        shell.execute('%runfile ' + repr(str(test_file2).replace("\\", "/")) +
+                      ' --wdir ' + repr(str(folder).replace("\\", "/")))
     qtbot.wait(1000)
     assert '1/0' in control.toPlainText()
 
@@ -3941,7 +3941,7 @@ def test_ipython_magic(main_window, qtbot, tmpdir, ipython, test_cell_magic):
 
     # Execute runcell
     with qtbot.waitSignal(shell.executed):
-        shell.execute("runcell(0, r'{}')".format(to_text_string(p)))
+        shell.execute("%runcell -i 0 {}".format(repr(to_text_string(p))))
     control = main_window.ipyconsole.get_widget().get_focus_widget()
 
     error_text = 'save this file with the .ipy extension'
@@ -4047,7 +4047,7 @@ def test_running_namespace_refresh(main_window, qtbot, tmpdir):
     main_window.debugger.clear_all_breakpoints()
 
     shell.execute(
-        "runfile(" + repr(str(file2)) + ")"
+        "%runfile " + repr(str(file2))
     )
 
     # Check nothing is in the variableexplorer
@@ -4071,7 +4071,7 @@ def test_running_namespace_refresh(main_window, qtbot, tmpdir):
     # Run file inside a debugger
     with qtbot.waitSignal(shell.executed):
         shell.execute(
-            "debugfile(" + repr(str(file1)) + ")"
+            "%debugfile " + repr(str(file1))
         )
 
     # continue
@@ -4115,11 +4115,10 @@ def test_debug_namespace(main_window, qtbot, tmpdir):
 
     with qtbot.waitSignal(shell.executed):
         shell.execute(
-            "debugfile(" +
+            "%debugfile " +
             repr(str(file2)) +
-            ", wdir=" +
-            repr(str(tmpdir)) +
-            ")"
+            " --wdir " +
+            repr(str(tmpdir))
         )
 
     # Check nothing is in the variableexplorer
@@ -4161,8 +4160,7 @@ def test_post_mortem(main_window, qtbot, tmpdir):
     test_file.write('raise RuntimeError\n')
 
     with qtbot.waitSignal(shell.executed):
-        shell.execute(
-            "runfile(" + repr(str(test_file)) + ", post_mortem=True)")
+        shell.execute("%runfile " + repr(str(test_file)) + " --post-mortem")
 
     assert "IPdb [" in control.toPlainText()
 
@@ -5176,7 +5174,7 @@ def test_locals_globals_var_debug(main_window, qtbot, tmpdir):
 
     # Run file inside a debugger
     with qtbot.waitSignal(shell.executed):
-        shell.execute("debugfile(" + repr(str(p)) + ")")
+        shell.execute("%debugfile " + repr(str(p)))
 
     # Add breakpoint on line 4 and go there
     with qtbot.waitSignal(shell.executed):
@@ -6285,5 +6283,54 @@ def test_recursive_debug_exception(main_window, qtbot):
     assert not shell.is_debugging()
 
 
+@flaky(max_runs=3)
+def test_runfile_namespace(main_window, qtbot, tmpdir):
+    """Test that the namespaces behave correctly when using runfile."""
+    baba_file = tmpdir.join("baba.py")
+    baba_file.write("baba = 1")
+    baba_path = to_text_string(baba_file)
+
+    # Create code
+    code = "\n".join([
+        "def fun():",
+        "    %runfile {}".format(repr(baba_path)),
+        '    print("test_locals", "baba" in locals(), "baba" in globals())',
+        "fun()",
+
+        "def fun():",
+        "    ns = {}",
+        "    %runfile {} --namespace ns".format(repr(baba_path)),
+        '    print("test_locals_namespace", "baba" in ns, "baba" in locals(), "baba" in globals())',
+        "fun()",
+
+        "ns = {}",
+        "%runfile {} --namespace ns".format(repr(baba_path)),
+        'print("test_globals_namespace", "baba" in ns, "baba" in globals())',
+
+        "%runfile {}".format(repr(baba_path)),
+        'print("test_globals", "baba" in globals())',
+    ])
+
+    p = tmpdir.join("test.ipy")
+    p.write(code)
+    test_file = to_text_string(p)
+
+    # Run file
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    qtbot.waitUntil(
+        lambda: shell.spyder_kernel_ready and shell._prompt_html is not None,
+        timeout=SHELL_TIMEOUT)
+
+    with qtbot.waitSignal(shell.sig_prompt_ready):
+        shell.execute('%runfile {}'.format(repr(test_file)))
+
+    # Check results
+    control = shell._control
+    assert "test_locals True False" in control.toPlainText()
+    assert "test_locals_namespace True False False" in control.toPlainText()
+    assert "test_globals_namespace True False" in control.toPlainText()
+    assert "test_globals True" in control.toPlainText()
+
+
 if __name__ == "__main__":
     pytest.main()
diff --git a/spyder/plugins/console/utils/interpreter.py b/spyder/plugins/console/utils/interpreter.py
index 81389130b9e..d01dabcf604 100644
--- a/spyder/plugins/console/utils/interpreter.py
+++ b/spyder/plugins/console/utils/interpreter.py
@@ -175,7 +175,7 @@ def run_command(self, cmd, new_prompt=True):
         # run command
         elif run_match:
             filename = guess_filename(run_match.groups()[0])
-            cmd = "runfile('%s', args=None)" % remove_backslashes(filename)
+            cmd = "%runfile " + repr(remove_backslashes(filename))
         # !cd system command
         elif cd_match:
             cmd = 'import os; os.chdir(r"%s")' % cd_match.groups()[0].strip()
diff --git a/spyder/plugins/console/widgets/main_widget.py b/spyder/plugins/console/widgets/main_widget.py
index 058bd58b3d7..a926fa24656 100644
--- a/spyder/plugins/console/widgets/main_widget.py
+++ b/spyder/plugins/console/widgets/main_widget.py
@@ -519,7 +519,8 @@ def run_script(self, filename=None, silent=False, args=None):
         logger.debug("Running script with %s", args)
         filename = osp.abspath(filename)
         rbs = remove_backslashes
-        command = "runfile('%s', args='%s')" % (rbs(filename), rbs(args))
+        command = '%runfile {} --args {}'.format(
+            repr(rbs(filename)), repr(rbs(args)))
 
         self.change_visibility(True, True)
 
diff --git a/spyder/plugins/help/utils/tutorial.rst b/spyder/plugins/help/utils/tutorial.rst
index 79fdf512df5..14c76472c9e 100644
--- a/spyder/plugins/help/utils/tutorial.rst
+++ b/spyder/plugins/help/utils/tutorial.rst
@@ -44,7 +44,7 @@ We are going to run this program as a first example:
 
    .. code-block:: python
 
-        In [1]: runfile('/File/Path/hello.py', wdir=r'/File/Path')
+        In [1]: %runfile '/File/Path/hello.py' --wdir
         Hello World
 
         In [2]:
@@ -53,7 +53,7 @@ If so, then you have just run your first Python program -- well done.
 
 .. note::
 
-   The particular paths shown inside ``runfile()`` will depend on where you have saved the file, but this is inserted by Spyder automatically.
+   The particular paths shown inside ``runfile`` will depend on where you have saved the file, but this is inserted by Spyder automatically.
 
 
 What happens when you execute a program?
diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py
index 181ca3bc2ba..bc2b7a1f249 100644
--- a/spyder/plugins/ipythonconsole/plugin.py
+++ b/spyder/plugins/ipythonconsole/plugin.py
@@ -645,10 +645,6 @@ def exec_files(
         console_namespace = params['console_namespace']
         run_method = params.get('run_method', 'runfile')
 
-        # Escape single and double quotes in fname and dirname.
-        # Fixes spyder-ide/spyder#2158.
-        filename = filename.replace("'", r"\'").replace('"', r'\"')
-
         self.run_script(
             filename,
             wdir,
diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
index 318d07a6255..cddcc3b20ad 100644
--- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
+++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py
@@ -1666,7 +1666,7 @@ def test_breakpoint_builtin(ipyconsole, qtbot, tmpdir):
 
     # Run file
     with qtbot.waitSignal(shell.executed):
-        shell.execute(f"runfile(filename=r'{str(file)}')")
+        shell.execute(f"%runfile {repr(str(file))}")
 
     # Assert we entered debugging after the print statement
     qtbot.wait(5000)
@@ -1774,7 +1774,7 @@ def test_pdb_comprehension_namespace(ipyconsole, qtbot, tmpdir):
 
     # Run file
     with qtbot.waitSignal(shell.executed):
-        shell.execute(f"debugfile(filename=r'{str(file)}')")
+        shell.execute(f"%debugfile {repr(str(file))}")
 
     # steps 4 times
     for i in range(4):
diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py
index 43f93effd4c..b673c7d90e4 100644
--- a/spyder/plugins/ipythonconsole/widgets/main_widget.py
+++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py
@@ -1837,15 +1837,20 @@ def norm(text):
             is_spyder_kernel = client.shellwidget.is_spyder_kernel
 
             if is_spyder_kernel:
-                # Use custom function
-                line = (str(
-                        "{}({}, '{}')").format(
-                                str(method),
-                                repr(cell_name),
-                                norm(filename).replace("'", r"\'")))
+                if isinstance(cell_name, int):
+                    line = "%{} -i {} {}"
+                else:
+                    line = "%{} -n {} {}"
+                line = line.format(
+                    method,
+                    repr(cell_name),
+                    repr(norm(filename))
+                )
             elif method == 'runcell':
                 # Use copy of cell
                 line = code.strip()
+            elif method == 'debugcell':
+                line = "%%debug\n" + code.strip()
             else:
                 # Can not use custom function on non-spyder kernels
                 client.shellwidget.append_html_message(
@@ -1899,22 +1904,21 @@ def run_script(self, filename, wdir, args, post_mortem, current_client,
                 method = "runfile"
             # If spyder-kernels, use runfile
             if client.shellwidget.is_spyder_kernel:
-                line = method + "('%s'" % (norm(filename))
+                line = "%{} {}".format(method , repr(norm(filename)))
                 if args:
-                    line += ", args='%s'" % norm(args)
-                if (
-                    wdir and client.shellwidget.is_external_kernel
-                    and os.path.samefile(wdir, os.path.dirname(filename))
-                ):
-                    # No working directory for external kernels
-                    wdir = ""
+                    line += ' --args {}'.format(repr(norm(args)))
                 if wdir:
-                    line += ", wdir='%s'" % norm(wdir)
+                    if wdir == os.path.dirname(filename):
+                        # No working directory for external kernels
+                        # If it has not been explicitely given
+                        if not client.shellwidget.is_external_kernel:
+                            line += " --wdir"
+                    else:
+                        line += " --wdir {}".format(repr(norm(wdir)))
                 if post_mortem:
-                    line += ", post_mortem=True"
+                    line += " --post-mortem"
                 if console_namespace:
-                    line += ", current_namespace=True"
-                line += ")"
+                    line += " --current-namespace"
 
             elif method in ["runfile", "debugfile"]:
                 # External, non spyder-kernels, use %run
diff --git a/spyder/plugins/run/container.py b/spyder/plugins/run/container.py
index f78e3d9b005..65f2843ecd5 100644
--- a/spyder/plugins/run/container.py
+++ b/spyder/plugins/run/container.py
@@ -153,7 +153,6 @@ def anonymous_execution_run():
 
             path = super_metadata['path']
             dirname = osp.dirname(path)
-            dirname = dirname.replace("'", r"\'").replace('"', r'\"')
 
             last_executor = last_executor_name
             if last_executor is None:
@@ -277,7 +276,6 @@ def process_run_dialog_result(self, result):
             else:
                 dirname = working_dir_opts['path']
 
-            dirname = dirname.replace("'", r"\'").replace('"', r'\"')
             working_dir_opts['path'] = dirname
 
             self.last_executed_file = uuid


Code review comments:
diff_hunk: @@ -1899,22 +1904,21 @@ def run_script(self, filename, wdir, args, post_mortem, current_client,
                 method = "runfile"
             # If spyder-kernels, use runfile
             if client.shellwidget.is_spyder_kernel:
-                line = method + "('%s'" % (norm(filename))
+                line = "%{} {}".format(method , repr(norm(filename)))
                 if args:
-                    line += ", args='%s'" % norm(args)
-                if (
-                    wdir and client.shellwidget.is_external_kernel
-                    and os.path.samefile(wdir, os.path.dirname(filename))
-                ):
-                    # No working directory for external kernels
-                    wdir = ""
+                    line += ' --args {}'.format(repr(norm(args)))
                 if wdir:
-                    line += ", wdir='%s'" % norm(wdir)
+                    if wdir == os.path.dirname(filename):
+                        # No working directory for external kernels
+                        # If it has not been explicitely given
path: spyder/plugins/ipythonconsole/widgets/main_widget.py
text: @user1:
```suggestion
                        # if it has not been explicitly given.
```