ipymagic: terminal Tab-completion overlay (Option A), and a real path bug
Adds the terminal-native picker: typing the trigger and pressing Tab pops
IPython's own completion menu (same widget, works in a real terminal or a
notebook -- ipykernel's completion goes through the same IPCompleter
matcher pipeline) instead of the Qt modal. Two triggers:
"..." component mode: Recent, Recommended (FrequencyCounts-ranked),
then a live substring-filterable walk of the namespace tree.
"...h" command mode (mnemonic: history): Recent/Recommended prior
*commands* (eco.logs.recent_commands/frequent_commands) -- no
namespace section, nothing to browse there.
Selecting an entry only ever inserts text at the cursor -- it never
executes, in either mode -- so the rest of the line can still be composed/
edited before submitting. This composes with the existing Qt-modal `...`
mechanism rather than conflicting with it: accepting a completion replaces
`...` in the buffer before the line is ever submitted, so if you don't use
Tab and just hit Enter, the AST-transform fallback still applies to
whatever literal `...` is left.
Real bug found while building this and wiring it through a live
IPCompleter (not just unit tests): eco.widgets.component_selector's
ComponentNode.name (and so pick_component_modal's return value) is
relative to `root` (confirmed against Alias.get_full_name(base=...), which
stops at base) -- not a standalone, globally-evaluable expression the way
this module had assumed everywhere. Both the Qt-modal path (_EllipsisPicker)
and this new completer were splicing that relative fragment in directly,
silently dropping the root's own name from the printed/inserted code.
Fixed with _full_path(root, relative_path), which reattaches the name
`root` is actually bound to (its own `.name`, matching this codebase's
`import eco.bernina as bernina` convention) -- verified against a real
IPCompleter.complete() call, not just the mocked unit tests, which is
exactly what surfaced the bug in the first place (a hand-fed "already
correct" path in the earlier Qt smoke test had been masking it).
Also fixed the smoke-test script that had encoded the same wrong
assumption, for anyone re-running it later.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+169
-12
@@ -38,12 +38,55 @@ literal ``...`` in place, so it fails exactly the way typing a real
|
||||
``Ellipsis`` there would (a clear `TypeError` if it's actually used, no
|
||||
silent auto-pick).
|
||||
|
||||
Deliberately namespace-tree-only for now -- see the module TODO below for
|
||||
picking from prior *commands* (eco.logs' kernel history) as a planned
|
||||
second mode, likely via a different trigger so the two don't collide.
|
||||
Terminal overlay (Tab-completion mode)
|
||||
--------------------------------------
|
||||
In a real terminal (or a notebook -- ipykernel's completion goes through
|
||||
the same IPCompleter matcher pipeline), typing the trigger and pressing
|
||||
Tab pops IPython's own native completion menu -- the same widget/rendering
|
||||
Python attribute completion already uses -- populated with three sections
|
||||
instead of attributes: Recent, Recommended (ranked by actual use-count via
|
||||
`eco.elements.recent.FrequencyCounts`, not just recency), and (component
|
||||
mode only) a live, filterable walk of the whole namespace tree. This is
|
||||
the preferred way to pick something in a terminal: it never blocks on a
|
||||
Qt window, and -- critically -- it only ever *inserts text at the cursor*,
|
||||
never executes anything, so you can keep composing/editing the line and
|
||||
submit whenever you're ready yourself:
|
||||
|
||||
In [1]: (1 / ...<TAB>
|
||||
.bernina.mono.energy recent
|
||||
.bernina.attenuator.transmission recommended
|
||||
.bernina.slit1.width namespace
|
||||
In [1]: (1 / bernina.mono.energy
|
||||
|
||||
Typing more after the trigger (`...ene<TAB>`) narrows the namespace
|
||||
section by substring match; Recent/Recommended stay unfiltered (picking
|
||||
something you use constantly shouldn't require spelling it first).
|
||||
|
||||
A second trigger, `...h` (mnemonic: history), does the same for prior
|
||||
*commands* (`eco.logs.recent_commands`/`frequent_commands`) instead of
|
||||
components -- no namespace section (commands have no tree to browse), and
|
||||
picking one always just inserts its text for further editing, the same
|
||||
"never auto-executes" rule as components. Both share one on/off switch
|
||||
(`start()`/`stop()`); there's no separate flag for the completion path
|
||||
since it always inserts rather than executes, so there's nothing about it
|
||||
that needs its own opt-out.
|
||||
|
||||
If you never press Tab and just hit Enter with a literal `...` still in
|
||||
the line, the `...`-in-an-expression mechanism above (the Qt modal picker)
|
||||
still applies as a fallback -- the two compose rather than conflict, since
|
||||
accepting a Tab-completion replaces the `...` in the buffer before the
|
||||
line is ever submitted, leaving nothing for the AST transform to find.
|
||||
"""
|
||||
import ast
|
||||
|
||||
from IPython.core.completer import SimpleCompletion, context_matcher
|
||||
|
||||
COMPONENT_TRIGGER = "..."
|
||||
COMMAND_TRIGGER = "...h"
|
||||
_RECENT_COUNT = 8
|
||||
_RECOMMENDED_COUNT = 8
|
||||
_NAMESPACE_LIMIT = 30
|
||||
|
||||
_state = {}
|
||||
|
||||
|
||||
@@ -51,6 +94,31 @@ def _is_marker(node):
|
||||
return isinstance(node, ast.Constant) and node.value is Ellipsis
|
||||
|
||||
|
||||
def _full_path(root, relative_path):
|
||||
"""`component_selector.build_tree`/`resolve_path` paths (and so
|
||||
`pick_component_modal`'s return value) are relative to `root` --
|
||||
plain `getattr` chains starting *from* root, never including root's
|
||||
own name (confirmed against `Alias.get_full_name(base=...)`, which
|
||||
stops at `base` and returns "" for `base` itself) -- not a standalone
|
||||
expression that means anything on its own. This combines it with the
|
||||
name `root` is actually bound to in the executing namespace (its own
|
||||
`.name`, matching this codebase's convention that a namespace object's
|
||||
`.name` already matches how it's imported at the top level -- e.g.
|
||||
`import eco.bernina as bernina` gives an object whose `.name` is
|
||||
already "bernina", and `eco.ipymagic.start()`'s own docstring assumes
|
||||
exactly this) so the printed/inserted code is directly executable and
|
||||
re-typeable on its own, not silently relative to some invisible root.
|
||||
|
||||
Contrast with `eco.elements.recent.RecentComponents`/`FrequencyCounts`
|
||||
paths, which come from `Alias.get_full_name()` with *no* base -- those
|
||||
are already absolute and must NOT be run through this again.
|
||||
"""
|
||||
root_name = getattr(root, "name", None) or "root"
|
||||
if not relative_path:
|
||||
return root_name
|
||||
return f"{root_name}.{relative_path}"
|
||||
|
||||
|
||||
class _EllipsisPicker(ast.NodeTransformer):
|
||||
"""Replaces every bare (non-subscript-slice) ``...`` in a parsed AST
|
||||
with the dotted-path expression of whatever the user picks, depth/
|
||||
@@ -87,7 +155,7 @@ class _EllipsisPicker(ast.NodeTransformer):
|
||||
print("eco.ipymagic: selection cancelled -- left `...` in place")
|
||||
return node
|
||||
self.picked_any = True
|
||||
replacement = ast.parse(path, mode="eval").body
|
||||
replacement = ast.parse(_full_path(self.root, path), mode="eval").body
|
||||
return ast.copy_location(replacement, node)
|
||||
|
||||
|
||||
@@ -116,17 +184,102 @@ class _AstTransformer:
|
||||
_ast_transformer = _AstTransformer()
|
||||
|
||||
|
||||
def _flatten_paths(node, out=None):
|
||||
"""All dotted paths in a `component_selector.ComponentNode` tree,
|
||||
depth-first (the root itself included, if it has a name)."""
|
||||
if out is None:
|
||||
out = []
|
||||
if node.name:
|
||||
out.append(node.name)
|
||||
for child in node.children:
|
||||
_flatten_paths(child, out)
|
||||
return out
|
||||
|
||||
|
||||
def _ranked_completions(recent_items, recommended_items, extra_items, query):
|
||||
"""Recent, then Recommended (recommended items already in Recent are
|
||||
dropped -- no point suggesting the same thing twice), then whatever
|
||||
`extra_items` are left (already excludes both, and already filtered
|
||||
by `query` by the caller -- Recent/Recommended stay unfiltered)."""
|
||||
completions = [SimpleCompletion(text=p, type="recent") for p in recent_items]
|
||||
recommended_items = [p for p in recommended_items if p not in recent_items]
|
||||
completions += [SimpleCompletion(text=p, type="recommended") for p in recommended_items]
|
||||
completions += [SimpleCompletion(text=p, type="namespace") for p in extra_items]
|
||||
return completions
|
||||
|
||||
|
||||
@context_matcher()
|
||||
def _component_matcher(context):
|
||||
token = context.token
|
||||
root = _state.get("root")
|
||||
if root is None or not token.startswith(COMPONENT_TRIGGER) or token.startswith(
|
||||
COMMAND_TRIGGER
|
||||
):
|
||||
return {"completions": []}
|
||||
|
||||
from eco.elements.recent import FrequencyCounts, RecentComponents
|
||||
from eco.widgets.component_selector import build_tree
|
||||
|
||||
namespace_name = getattr(root, "name", None)
|
||||
recent_paths = RecentComponents(namespace_name=namespace_name).all()[:_RECENT_COUNT]
|
||||
recommended_paths = FrequencyCounts(namespace_name=namespace_name).top(
|
||||
_RECOMMENDED_COUNT * 2
|
||||
)[:_RECOMMENDED_COUNT]
|
||||
|
||||
query = token[len(COMPONENT_TRIGGER) :].lower()
|
||||
exclude = set(recent_paths) | set(recommended_paths)
|
||||
# build_tree's paths are relative to root (see _full_path's docstring)
|
||||
# -- convert to absolute *before* dedup/query matching, so they compare
|
||||
# correctly against Recent/Recommended's already-absolute paths.
|
||||
namespace_paths = [
|
||||
full
|
||||
for full in (_full_path(root, p) for p in _flatten_paths(build_tree(root)))
|
||||
if full not in exclude and (not query or query in full.lower())
|
||||
][:_NAMESPACE_LIMIT]
|
||||
|
||||
completions = _ranked_completions(recent_paths, recommended_paths, namespace_paths, query)
|
||||
return {"completions": completions, "suppress": True}
|
||||
|
||||
|
||||
@context_matcher()
|
||||
def _command_matcher(context):
|
||||
token = context.token
|
||||
if _state.get("root") is None or not token.startswith(COMMAND_TRIGGER):
|
||||
return {"completions": []}
|
||||
|
||||
from eco import logs
|
||||
|
||||
recent_cmds = logs.recent_commands(n=_RECENT_COUNT)
|
||||
recommended_cmds = logs.frequent_commands(n=_RECOMMENDED_COUNT * 2)[:_RECOMMENDED_COUNT]
|
||||
|
||||
query = token[len(COMMAND_TRIGGER) :].lower()
|
||||
|
||||
def _keep(cmd):
|
||||
return not query or query in cmd.lower()
|
||||
|
||||
recent_cmds = [c for c in recent_cmds if _keep(c)]
|
||||
recommended_cmds = [c for c in recommended_cmds if _keep(c)]
|
||||
|
||||
completions = _ranked_completions(recent_cmds, recommended_cmds, [], query)
|
||||
return {"completions": completions, "suppress": True}
|
||||
|
||||
|
||||
def start(root, kind_filter="All", bookmarks=None, recent=None):
|
||||
"""Register the `...` picker on the *current* IPython shell.
|
||||
"""Register the `...` picker on the *current* IPython shell: both the
|
||||
Tab-completion overlay (`...`/`...h`, terminal and notebook alike) and
|
||||
the Qt-modal fallback for a `...` that reaches Enter unexpanded.
|
||||
|
||||
`root`: the namespace/Assembly tree to pick from (e.g. `bernina`).
|
||||
`kind_filter`/`bookmarks`/`recent`: passed straight through to the
|
||||
picker window each time (see `eco.widgets.component_selector_qt.
|
||||
pick_component_modal`) -- left as `None` picks up that frontend's own
|
||||
per-user, per-namespace defaults, same as opening it any other way.
|
||||
Qt-modal picker each time it opens (see `eco.widgets.
|
||||
component_selector_qt.pick_component_modal`) -- left as `None` picks up
|
||||
that frontend's own per-user, per-namespace defaults, same as opening
|
||||
it any other way. The Tab-completion overlay always uses its own
|
||||
per-namespace Recent/Recommended (see `eco.elements.recent`), not
|
||||
these -- there's no window to hand a shared instance to.
|
||||
|
||||
Calling this again just repoints `root` (etc.) for the next pick --
|
||||
it does not install a second hook, so re-running a startup cell is
|
||||
it does not install the hooks twice, so re-running a startup cell is
|
||||
safe.
|
||||
"""
|
||||
from IPython import get_ipython
|
||||
@@ -142,11 +295,15 @@ def start(root, kind_filter="All", bookmarks=None, recent=None):
|
||||
|
||||
if not getattr(ip, "_eco_ipymagic_installed", False):
|
||||
ip.ast_transformers.append(_ast_transformer)
|
||||
ip.Completer.custom_matchers.extend([_component_matcher, _command_matcher])
|
||||
ip._eco_ipymagic_installed = True
|
||||
|
||||
|
||||
def stop():
|
||||
"""Turn picking off (`...` goes back to being a plain literal) without
|
||||
needing a shell restart -- e.g. a notebook cell that wants to type a
|
||||
real Ellipsis afterward."""
|
||||
"""Turn picking off (`...`/`...h` go back to being plain text/a plain
|
||||
literal) without needing a shell restart -- e.g. a notebook cell that
|
||||
wants to type a real Ellipsis afterward. The completion matchers stay
|
||||
registered (harmless: both already no-op whenever `_state["root"]` is
|
||||
`None`, checked first) -- only `_state["root"]` is cleared, same as
|
||||
the Qt-modal fallback's own gate."""
|
||||
_state["root"] = None
|
||||
|
||||
+157
-6
@@ -18,9 +18,15 @@ class _FakeEvents:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeCompleter:
|
||||
def __init__(self):
|
||||
self.custom_matchers = []
|
||||
|
||||
|
||||
class _FakeShell:
|
||||
def __init__(self):
|
||||
self.ast_transformers = []
|
||||
self.Completer = _FakeCompleter()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -30,7 +36,20 @@ def _reset_state():
|
||||
ipymagic._state.clear()
|
||||
|
||||
|
||||
def _transform_source(source, root="the_root"):
|
||||
class _NamedRoot:
|
||||
"""A pick_component_modal path is relative to root (see
|
||||
ipymagic._full_path's docstring) -- these tests need a `root` with a
|
||||
real `.name` for the resulting expanded code to come out right, not
|
||||
a bare string (which has no `.name` and would fall back to a dummy
|
||||
"root" prefix)."""
|
||||
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
|
||||
def _transform_source(source, root=None):
|
||||
if root is None:
|
||||
root = _NamedRoot("bernina")
|
||||
tree = ast.parse(source)
|
||||
picker = ipymagic._EllipsisPicker(root, "All", None, None)
|
||||
new_tree = picker.visit(tree)
|
||||
@@ -41,7 +60,7 @@ def _transform_source(source, root="the_root"):
|
||||
def test_bare_ellipsis_expression_is_replaced_with_the_picked_path(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"eco.widgets.component_selector_qt.pick_component_modal",
|
||||
lambda root, **kw: ("bernina.mono.energy", object()),
|
||||
lambda root, **kw: ("mono.energy", object()), # relative to root, like the real picker
|
||||
)
|
||||
out, picked_any = _transform_source("(1 / ...).plot()")
|
||||
assert out == "(1 / bernina.mono.energy).plot()"
|
||||
@@ -49,7 +68,7 @@ def test_bare_ellipsis_expression_is_replaced_with_the_picked_path(monkeypatch):
|
||||
|
||||
|
||||
def test_two_markers_are_resolved_left_to_right_in_source_order(monkeypatch):
|
||||
picks = iter(["bernina.mono.energy", "bernina.attenuator.transmission"])
|
||||
picks = iter(["mono.energy", "attenuator.transmission"])
|
||||
seen_roots = []
|
||||
|
||||
def fake_pick(root, **kw):
|
||||
@@ -57,12 +76,13 @@ def test_two_markers_are_resolved_left_to_right_in_source_order(monkeypatch):
|
||||
return next(picks), object()
|
||||
|
||||
monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fake_pick)
|
||||
root = _NamedRoot("bernina")
|
||||
out, picked_any = _transform_source(
|
||||
"cen = (left := ...) - (right := ...)", root="bernina"
|
||||
"cen = (left := ...) - (right := ...)", root=root
|
||||
)
|
||||
assert out == "cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission)"
|
||||
assert picked_any is True
|
||||
assert seen_roots == ["bernina", "bernina"]
|
||||
assert seen_roots == [root, root]
|
||||
|
||||
|
||||
def test_subscript_slice_ellipsis_is_left_alone(monkeypatch):
|
||||
@@ -94,7 +114,7 @@ def test_transform_is_a_noop_when_start_has_not_been_called():
|
||||
assert out == "(1 / ...).plot()"
|
||||
|
||||
|
||||
def test_start_registers_the_transformer_once(monkeypatch):
|
||||
def test_start_registers_the_transformer_and_matchers_once(monkeypatch):
|
||||
shell = _FakeShell()
|
||||
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
|
||||
|
||||
@@ -102,6 +122,10 @@ def test_start_registers_the_transformer_once(monkeypatch):
|
||||
ipymagic.start("bernina") # idempotent -- must not install a second hook
|
||||
|
||||
assert shell.ast_transformers == [ipymagic._ast_transformer]
|
||||
assert shell.Completer.custom_matchers == [
|
||||
ipymagic._component_matcher,
|
||||
ipymagic._command_matcher,
|
||||
]
|
||||
assert ipymagic._state["root"] == "bernina"
|
||||
|
||||
|
||||
@@ -121,3 +145,130 @@ def test_stop_makes_the_transform_a_noop_again(monkeypatch):
|
||||
tree = ast.parse("(1 / ...).plot()")
|
||||
out = ast.unparse(ipymagic._ast_transformer.visit(tree))
|
||||
assert out == "(1 / ...).plot()"
|
||||
|
||||
|
||||
# -- Tab-completion overlay (_component_matcher / _command_matcher) --------
|
||||
|
||||
|
||||
class _Ctx:
|
||||
"""Just enough of CompletionContext for these matchers -- both only
|
||||
ever read `.token`."""
|
||||
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
|
||||
class _FakeRoot:
|
||||
name = "bernina"
|
||||
|
||||
|
||||
def _texts(result):
|
||||
return [c.text for c in result["completions"]]
|
||||
|
||||
|
||||
def _types(result):
|
||||
return [c.type for c in result["completions"]]
|
||||
|
||||
|
||||
def test_component_matcher_returns_nothing_when_not_started():
|
||||
assert ipymagic._component_matcher(_Ctx("...")) == {"completions": []}
|
||||
|
||||
|
||||
def test_component_matcher_ignores_tokens_without_the_trigger(monkeypatch):
|
||||
ipymagic._state["root"] = _FakeRoot()
|
||||
assert ipymagic._component_matcher(_Ctx("bernina")) == {"completions": []}
|
||||
|
||||
|
||||
def test_component_matcher_defers_to_command_matcher_for_the_history_trigger(monkeypatch):
|
||||
ipymagic._state["root"] = _FakeRoot()
|
||||
assert ipymagic._component_matcher(_Ctx("...h")) == {"completions": []}
|
||||
|
||||
|
||||
def _fake_node(name, children=()):
|
||||
return type("N", (), {"name": name, "children": list(children)})()
|
||||
|
||||
|
||||
def _fake_tree_relative_to_bernina():
|
||||
# build_tree's own node names are relative to root (see _full_path's
|
||||
# docstring) -- root's own name comes back "" (Alias.get_full_name
|
||||
# stops at base), children are unprefixed ("mono.energy", not
|
||||
# "bernina.mono.energy").
|
||||
return _fake_node(
|
||||
"",
|
||||
children=[
|
||||
_fake_node("mono.energy"),
|
||||
_fake_node("attenuator.transmission"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_component_matcher_sections_are_recent_then_recommended_then_namespace(monkeypatch):
|
||||
ipymagic._state["root"] = _FakeRoot()
|
||||
monkeypatch.setattr(
|
||||
"eco.elements.recent.RecentComponents.all", lambda self: ["bernina.slit1.width"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"eco.elements.recent.FrequencyCounts.top",
|
||||
lambda self, n: ["bernina.slit1.width", "bernina.mono.energy"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"eco.widgets.component_selector.build_tree",
|
||||
lambda root: _fake_tree_relative_to_bernina(),
|
||||
)
|
||||
|
||||
result = ipymagic._component_matcher(_Ctx("..."))
|
||||
|
||||
# bernina.slit1.width: recent, wins over also being "recommended".
|
||||
# bernina.mono.energy: recommended (already absolute -- RecentComponents/
|
||||
# FrequencyCounts paths come from Alias.get_full_name() with no base,
|
||||
# unlike build_tree's root-relative ones) -- also excluded from the
|
||||
# namespace section below since it's already shown once.
|
||||
# bernina.attenuator.transmission: plain namespace, root-prefixed since
|
||||
# build_tree gave back the relative "attenuator.transmission".
|
||||
assert _texts(result) == [
|
||||
"bernina.slit1.width",
|
||||
"bernina.mono.energy",
|
||||
"bernina.attenuator.transmission",
|
||||
]
|
||||
assert _types(result) == ["recent", "recommended", "namespace"]
|
||||
assert result["suppress"] is True
|
||||
|
||||
|
||||
def test_component_matcher_query_narrows_namespace_but_not_recent(monkeypatch):
|
||||
ipymagic._state["root"] = _FakeRoot()
|
||||
monkeypatch.setattr(
|
||||
"eco.elements.recent.RecentComponents.all", lambda self: ["bernina.slit1.width"]
|
||||
)
|
||||
monkeypatch.setattr("eco.elements.recent.FrequencyCounts.top", lambda self, n: [])
|
||||
monkeypatch.setattr(
|
||||
"eco.widgets.component_selector.build_tree",
|
||||
lambda root: _fake_tree_relative_to_bernina(),
|
||||
)
|
||||
|
||||
result = ipymagic._component_matcher(_Ctx("...ene"))
|
||||
|
||||
# "slit1.width" doesn't match "ene" but stays -- Recent is unfiltered.
|
||||
# Of the namespace section, only "bernina.mono.energy" (via "ene") matches.
|
||||
assert _texts(result) == ["bernina.slit1.width", "bernina.mono.energy"]
|
||||
|
||||
|
||||
def test_command_matcher_returns_nothing_when_not_started():
|
||||
assert ipymagic._command_matcher(_Ctx("...h")) == {"completions": []}
|
||||
|
||||
|
||||
def test_command_matcher_ignores_the_plain_component_trigger(monkeypatch):
|
||||
ipymagic._state["root"] = _FakeRoot()
|
||||
assert ipymagic._command_matcher(_Ctx("...")) == {"completions": []}
|
||||
|
||||
|
||||
def test_command_matcher_has_recent_and_recommended_but_no_namespace_section(monkeypatch):
|
||||
ipymagic._state["root"] = _FakeRoot()
|
||||
monkeypatch.setattr("eco.logs.recent_commands", lambda n: ["mono.mv(5)"])
|
||||
monkeypatch.setattr(
|
||||
"eco.logs.frequent_commands", lambda n: ["daq.compare_channels()", "mono.mv(5)"]
|
||||
)
|
||||
|
||||
result = ipymagic._command_matcher(_Ctx("...h"))
|
||||
|
||||
assert _texts(result) == ["mono.mv(5)", "daq.compare_channels()"]
|
||||
assert _types(result) == ["recent", "recommended"]
|
||||
|
||||
Reference in New Issue
Block a user