Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions IPython/core/historyapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import sqlite3
from contextlib import closing
from pathlib import Path

from traitlets.config.application import Application
Expand Down Expand Up @@ -50,8 +51,10 @@ class HistoryTrim(BaseIPythonApplication):
def start(self):
profile_dir = Path(self.profile_dir.location)
hist_file = profile_dir / "history.sqlite"
with sqlite3.connect(hist_file) as con:

# Connections must be closed, not merely committed, before the file
# shuffling below: Windows refuses to unlink or rename a database
# that still has open handles.
with closing(sqlite3.connect(hist_file)) as con:
# Grab the recent history from the current database.
inputs = list(con.execute('SELECT session, line, source, source_raw FROM '
'history ORDER BY session DESC, line DESC LIMIT ?', (self.keep+1,)))
Expand All @@ -78,7 +81,7 @@ def start(self):
# Make sure we don't interfere with an existing file.
i += 1
new_hist_file = profile_dir / ("history.sqlite.new" + str(i))
with sqlite3.connect(new_hist_file) as new_db:
with closing(sqlite3.connect(new_hist_file)) as new_db:
new_db.execute("""CREATE TABLE IF NOT EXISTS sessions (session integer
primary key autoincrement, start timestamp,
end timestamp, num_cmds integer, remark text)""")
Expand Down
5 changes: 3 additions & 2 deletions IPython/core/oinspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ def _get_info(
required_parameters = [
parameter
for parameter in inspect.signature(hook).parameters.values()
if parameter.default != inspect.Parameter.default
if parameter.default is inspect.Parameter.empty
]
if len(required_parameters) == 1:
res = hook(hook_data)
Expand Down Expand Up @@ -886,7 +886,8 @@ def info(self, obj, oname="", info=None, detail_level=0) -> InfoDict:
prelude = ""
if info and info.parent is not None and hasattr(info.parent, HOOK_NAME):
parents_docs_dict = getattr(info.parent, HOOK_NAME)
parents_docs = parents_docs_dict.get(att_name, None)
if isinstance(parents_docs_dict, dict):
parents_docs = parents_docs_dict.get(att_name, None)
out: InfoDict = cast(
InfoDict,
{
Expand Down
7 changes: 5 additions & 2 deletions IPython/extensions/deduperreload/deduperreload.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import platform
import sys
import textwrap
import tokenize
from types import ModuleType
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from collections.abc import Generator, Iterable
Expand Down Expand Up @@ -215,7 +216,9 @@ def update_sources(self) -> None:
self.source_by_modname[new_modname] = ""
continue
try:
with open(fname, "r", encoding="utf8") as f:
# tokenize.open honors PEP 263 coding cookies and defaults
# to utf-8, like the import system does.
with tokenize.open(fname) as f:
self.source_by_modname[new_modname] = f.read()
except Exception as e:
logger = logging.getLogger("autoreload")
Expand Down Expand Up @@ -552,7 +555,7 @@ def maybe_reload_module(self, module: ModuleType) -> bool:
if (fname := get_module_file_name(module)) is None:
return False
try:
with open(fname, "r", encoding="utf8") as f:
with tokenize.open(fname) as f:
new_source_code = f.read()
except Exception as e:
logger = logging.getLogger("autoreload")
Expand Down
30 changes: 30 additions & 0 deletions IPython/extensions/tests/test_deduperreload.py
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,36 @@ def foo(y):
mod = sys.modules[mod_name]
assert mod.foo(0) == 5

def test_deduperreloader_pep263_encoding(self):
"""Source with a non-utf-8 PEP 263 coding cookie can be read (gh-15193)."""
self.shell.magic_autoreload("2")
mod_name, mod_fn = self.get_module()
code_v1 = squish_text(
'''
# -*- coding: latin-1 -*-
CAFE = "café"
def foo(y):
return y + 3
'''
)
with open(mod_fn, "wb") as f:
f.write(code_v1.encode("latin-1"))
self.created_temp_modules.add(mod_name)
self.shell.run_code("import %s" % mod_name)
self.shell.run_code("pass")
# The latin-1 encoded source is not valid utf-8, but must still be
# readable for the deduper to consider the module patchable.
deduper = self.shell.auto_magics._reloader.deduper_reloader
assert deduper.source_by_modname[mod_name] != ""
if platform.system().lower() != "darwin":
time.sleep(1.05)
with open(mod_fn, "wb") as f:
f.write(code_v1.replace("y + 3", "y + 5").encode("latin-1"))
self.shell.run_code("pass")
mod = sys.modules[mod_name]
assert mod.foo(0) == 5
assert mod.CAFE == "café"

def test_super(self):
self.shell.magic_autoreload("2")
mod_name, mod_fn = self.new_module(
Expand Down
24 changes: 22 additions & 2 deletions IPython/utils/generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,38 @@

from __future__ import annotations

import warnings
from typing import Any

from IPython.core.error import TryNext
from functools import singledispatch


@singledispatch
def inspect_object(obj: Any) -> None:
"""Called when you do obj?"""
def _inspect_object(obj: Any) -> None:
"""Called when you do obj?

.. deprecated:: 9.15
`inspect_object` is deprecated and will be removed in a future
version. It is no longer used within IPython, so registering
handlers on it has no effect.
"""
raise TryNext


def __getattr__(name: str) -> Any:
if name == "inspect_object":
warnings.warn(
"inspect_object is deprecated since IPython 9.15 and will be "
"removed in a future version. It is no longer used within "
"IPython, so registering handlers on it has no effect.",
DeprecationWarning,
stacklevel=2,
)
return _inspect_object
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


@singledispatch
def complete_object(obj: Any, prev_completions: list[str]) -> list[str]:
"""Custom completer dispatching for python objects.
Expand Down
8 changes: 4 additions & 4 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ coverage:
paths:
- '!**/tests/**'
- '!**/testing/**'
threshold: 0.1%
threshold: 0.2%
tests:
target: auto
paths:
- '**/tests/**'
- '**/testing/**'
threshold: 0.1%
threshold: 0.2%
flag_management:
default_rules:
statuses:
- type: patch
target: auto%
threshold: 0.1%
threshold: 0.2%
- type: project
target: auto%
threshold: 0.1%
threshold: 0.2%
individual_flags:
- name: unit-tests
paths:
Expand Down
7 changes: 7 additions & 0 deletions docs/source/whatsnew/pr/deprecate-inspect-object.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Deprecation of ``IPython.utils.generics.inspect_object``
=========================================================

``IPython.utils.generics.inspect_object`` is deprecated since IPython 9.15 and
will be removed in a future version. It is no longer used within IPython, so
registering handlers on it has no effect. ``complete_object`` is unaffected.
See :ghissue:`15068`.
21 changes: 21 additions & 0 deletions docs/source/whatsnew/pr/fix-oinspect-getattr-and-path-spaces.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Fix oinspect TypeError with objects using generic ``__getattr__``
=================================================================

Objects whose ``__getattr__`` returns something other than ``dict`` for
``__custom_documentations__`` (e.g. polars ``Expr``, which returns a new
``Expr`` for any attribute name) no longer cause a ``TypeError`` when
inspected with ``?`` or :func:`%pinfo`. The lookup is now guarded with
``isinstance(..., dict)``. :ghissue:`15072`

Also fixed an incorrect comparison in the MIME-hook inspection path
(``inspect.Parameter.default`` → ``inspect.Parameter.empty``) that
accidentally relied on a property-object inequality to filter required
parameters.

Fix test failures when IPython source path contains spaces
==========================================================

``test_exit_code_signal`` in :file:`tests/test_interactiveshell.py` now
uses :func:`shlex.quote` when interpolating ``sys.executable`` and the
temporary filename into a shell command string, preventing test failures
on systems where either path contains spaces. :ghissue:`15100`
22 changes: 22 additions & 0 deletions tests/test_generics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Tests for IPython.utils.generics."""

import warnings

import pytest

from IPython.core.error import TryNext
from IPython.utils import generics


def test_inspect_object_deprecated():
with pytest.warns(DeprecationWarning, match="inspect_object is deprecated"):
func = generics.inspect_object
# The returned object still behaves as before.
with pytest.raises(TryNext):
func(object())


def test_complete_object_not_deprecated():
with warnings.catch_warnings():
warnings.simplefilter("error")
assert callable(generics.complete_object)
75 changes: 74 additions & 1 deletion tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
import gc
import os
import sqlite3
import subprocess
import sys
import tempfile
from contextlib import closing
from datetime import datetime
from pathlib import Path

Expand Down Expand Up @@ -261,18 +263,25 @@ def test_hist_file_config(hmmax3):
cfg = Config()
tfile = tempfile.NamedTemporaryFile(delete=False)
cfg.HistoryManager.hist_file = Path(tfile.name)
hm = None
try:
hm = HistoryManager(shell=get_ipython(), config=cfg)
assert hm.hist_file == cfg.HistoryManager.hist_file
finally:
if hm is not None:
# Stop the saving thread and close the database, otherwise the
# thread can briefly hold a strong reference to the manager,
# making the instance-leak check in the fixture teardown flaky
# (gh-15161).
hm.save_thread.stop()
hm.db.close()
try:
Path(tfile.name).unlink()
except OSError:
# same catch as in testing.tools.TempFileMixin
# On Windows, even though we close the file, we still can't
# delete it. I have no clue why
pass
HistoryManager.__max_inst = 1


def test_histmanager_memory_fallback_reopens_db(hmmax3, tmp_path, caplog):
Expand Down Expand Up @@ -468,3 +477,67 @@ def test_calling_run_cell(hmmax2):

ip.history_manager = hist_manager_ori
assert session_number == new_session_number, ValueError(f"{session_number} != {new_session_number}")


def _make_history_db(hist_file: Path, n_entries: int) -> None:
"""Create a history database with the standard schema and some entries."""
with closing(sqlite3.connect(hist_file)) as con:
con.execute(
"""CREATE TABLE sessions (session integer
primary key autoincrement, start timestamp,
end timestamp, num_cmds integer, remark text)"""
)
con.execute(
"""CREATE TABLE history
(session integer, line integer, source text, source_raw text,
PRIMARY KEY (session, line))"""
)
con.execute(
"""CREATE TABLE output_history
(session integer, line integer, output text,
PRIMARY KEY (session, line))"""
)
now = datetime.now().isoformat(sep=" ")
con.execute(
"INSERT INTO sessions VALUES (1, ?, ?, ?, '')", (now, now, n_entries)
)
con.executemany(
"INSERT INTO history VALUES (1, ?, ?, ?)",
[(i, "code %d" % i, "code %d" % i) for i in range(n_entries)],
)
con.commit()


@pytest.mark.parametrize(
"subcommand, kept",
[
(["trim", "--keep=2"], 2),
(["clear", "-f"], 0),
],
)
def test_history_trim_cli(tmp_path, subcommand, kept):
"""`ipython history trim/clear` must replace the database file.

All sqlite connections to the old database have to be closed before it is
unlinked, otherwise this fails on Windows and leaves a stray
``history.sqlite.new`` file behind (gh-15241).
"""
profile_dir = tmp_path / "profile_default"
profile_dir.mkdir()
hist_file = profile_dir / "history.sqlite"
_make_history_db(hist_file, 5)

env = os.environ.copy()
env["IPYTHONDIR"] = str(tmp_path)
proc = subprocess.run(
[sys.executable, "-m", "IPython", "history"] + subcommand,
capture_output=True,
text=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert list(profile_dir.glob("history.sqlite.new*")) == []
with closing(sqlite3.connect(hist_file)) as con:
rows = list(con.execute("SELECT source FROM history"))
assert len(rows) == kept
3 changes: 2 additions & 1 deletion tests/test_interactiveshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import asyncio
import ast
import os
import shlex
import signal
import shutil
import sys
Expand Down Expand Up @@ -657,7 +658,7 @@ def test_exit_code_signal(self):
"signal.setitimer(signal.ITIMER_REAL, 0.1)\n"
"time.sleep(1)\n"
)
self.system("%s %s" % (sys.executable, self.fname))
self.system("%s %s" % (shlex.quote(sys.executable), shlex.quote(self.fname)))
self.assertEqual(ip.user_ns["_exit_code"], -signal.SIGALRM)

@onlyif_cmds_exist("csh")
Expand Down
24 changes: 24 additions & 0 deletions tests/test_oinspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,30 @@ def prop(self, v):
assert re.search(r"Type:\s+NoneType", captured.out)


def test_pinfo_getattr_object(capsys):
"""Test that pinfo doesn't crash on objects with generic __getattr__.

Regression test for issue #15072: polars Expr objects have a generic
__getattr__ that returns a new Expr for any attribute name, which caused
TypeError when pinfo tried to call .get() on the returned Expr instead of
a dict.
"""
obj_def = """class ExprLike:
'''A class that simulates polars.Expr behavior with generic __getattr__.'''
def __getattr__(self, name):
# Return self for any attribute, simulating polars Expr behavior
return self
"""
ip.run_cell(obj_def)
ip.run_cell("expr_like = ExprLike()")

# This should not raise TypeError
ip.run_cell("expr_like.some_attr?")
captured = capsys.readouterr()
# Should get some output without crashing
assert "ExprLike" in captured.out or "Class" in captured.out


def test_pinfo_magic():
with AssertPrints("Docstring:"):
ip._inspect("pinfo", "lsmagic", detail_level=0)
Expand Down
Loading
Loading