Implements steps 1–3 of CUSTOMIZATION_API_DESIGN.md,
shipped as a Preview: the action registry with contexts, the ACTIONS
config variable with its invocation-time façade, and EVENT_HOOKS. Step 4
(exposed registries — VIEWER_RENDERERS and friends) is not implemented.
Motivated by issue #287.
User-facing documentation: doc/CUSTOMIZATION_FEATURE.md.
| File | Holds |
|---|---|
xefm/actions.py |
Action, ActionRegistry, the context names, the built-in action tables |
xefm/user_api.py |
ActionContext, PaneApi, EntryInfo, EventHooks, the ACTIONS/EVENT_HOOKS loader, API_VERSION |
xefm/config.py |
per-context key resolution inside KeyBindings |
xefm/app.py |
the filer handler table, _run_action, hook firing |
xefm/{text,image,diff,directory_diff}_viewer.py |
per-viewer handler tables |
The registry holds what an action is — name, context, description, default keys, selection requirement. It does not hold what a built-in action does.
That split is forced by binding: a built-in’s behavior is a bound method of a
live XeFMApp or a live viewer, and neither exists when xefm/actions.py is
imported. So each owner keeps its own {name: handler} table, built lazily on
first key and cached on the instance:
XeFMApp._filer_handlers() → {name: (callable, redraw)}TextViewer._raw_handlers(), ImageViewer._handlers_table(),
DiffViewer._handlers_table(), DirectoryDiffView._handlers_table() →
{name: callable}Action.func is therefore None for a built-in, and holds the callable only
for a user action, where the config supplies it directly and there is nothing
to bind against. Action.source ("builtin" / "user") is what separates the
two everywhere else.
test_every_registered_filer_action_has_a_handler asserts the two halves stay
in agreement — a name in the registry with no handler is a key that resolves to
nothing, and a handler with no registered name is a key that never resolves.
redraw columndispatch() has always returned “does the screen need a repaint”, and the old
if/elif chain expressed that by where it returned from: falling through to the
final return True, or returning False early for anything that opens a dialog
and drives its own redraw. The table keeps that distinction explicitly —
True, False, or None for the handful (copy_files, delete_files,
create_archive, …) that decide per call and return the flag themselves.
Six contexts: filer, text_viewer, image_viewer, file_diff, dir_diff,
and common. Every context inherits common, which holds quit, help,
isearch and edit_file — the four names that mean something on every surface.
Each context supplies its own handler for an inherited name, which is how one
quit binding closes a viewer here and quits the app there.
file_diff rather than diff_viewer (which is what the module is called): the
two diff surfaces are symmetric, and diff_viewer next to dir_diff never said
which was which. A context is a user-facing name in KEY_BINDINGS, so it is
named for the reader rather than after its module — dir_diff never matched
directory_diff_viewer.py either.
Dialogs (sort, rename, input, …) deliberately have no context. They are forms, not customization surfaces.
KEY_BINDINGS stayed flatKEY_BINDINGS is still one dictionary, so context lives in the name — but only
where a name genuinely belongs to one surface. The test is what the bare name
would mean:
file_diff.next_block,
image_viewer.next, dir_diff.switch_side.toggle_wrap, change_encoding, copy_files. A name may be registered
in several contexts, each with its own handler — copy_files already is, in
filer and dir_diff — so one binding covers all of them, and adding the
second implementation later changes nothing in anyone’s config.filer is the
namespace’s incumbent, every config in existence binds cursor_up, and letting
the absence of a dot mean “not one specific viewer” is worth more than
uniformity.A config narrows an unqualified action to one surface with the context. prefix
('file_diff.quit': ['X']) — which is not an alias and never warns; see
§3. Note the two forms are mutually exclusive per action: if both scroll_up and
file_diff.scroll_up were registered as separate actions, a config key
file_diff.scroll_up would bind the second rather than narrowing the first, and
both keys would end up live. An action is named one way or the other.
The design sketch wrote diff.next_block; this uses file_diff.next_block.
Making the prefix exactly the context name means the resolution rule is one
sentence with no lookup table.
Nine actions that already existed had to be renamed to fit — the image_*
family. See “Renamed actions” below.
A nested KEY_BINDINGS_BY_CONTEXT was rejected in the design and is not here:
two dictionaries with overlapping meaning, and every existing config would sit
in the legacy one forever.
KeyBindings.find_action_for_event(event, has_selection, context=None) keeps
its old flat behavior when context is None — that path is still the public
API and several tests exercise it. With a context it consults a compiled table
built by _context_entries, which is a list of (parsed_key, action, selection)
in match order.
Three sources feed it, each supplying keys for actions the previous one did not:
'file_diff.quit': ['x'], where
quit is a registered name in that context and file_diff.quit is not.
A rebind scoped to one surface, so it wins over the unqualified one.Only names the context understands — its own table plus inherited common — are
considered at all.
_copy_missing_fields can add a whole missing config field to an old config
but never a missing key inside KEY_BINDINGS. A config written before an action
existed has no entry for it and never will, so without per-action defaults every
action added after a user first wrote their config would be unreachable forever.
This is the general mechanism that replaces the hand-rolled fallbacks the
viewers had grown for exactly this problem — TextViewer._wrap_pressed,
ImageViewer._pressed / _pressed_key, each with a hardcoded historical key.
Those are deleted. See “Behavior changes” below for what that means for a config
old enough to have relied on them.
Each action’s default keys live in exactly one place:
_config.py template reads its
defaults from the template, lazily, via actions._template_bindings().
There is no second copy to drift.duplicate_files, which is deliberately unbound)
declares default_keys on its Action.The template documents those in a comment block rather than as live entries.
Adding them as entries would have put them in the flat _key_to_actions table
too, where they would compete with file-list keys in the context-free lookup
that still exists. The viewer actions the template already listed stay listed,
under their current names — renaming a key is not the same as adding one.
The refactor’s central claim is that the file list resolves exactly as before.
test_filer_resolves_every_default_key_exactly_as_the_flat_table_did checks it
directly: for the shipped keymap, every key it binds, both selection states,
flat result must equal filer-context result.
It holds because the template’s dict order already put every file-list action
ahead of every viewer-local one, so the flat lookup was already picking the
file-list meaning of W, -, ↓ and the rest. What changes is that this no
longer depends on dict order — a user config that happens to list its wrap
binding before compare_selection used to resolve W to the viewer action in
the file list, and now cannot.
The compiled table is cached per context on the KeyBindings instance and
dropped whenever ActionRegistry.generation changes. The generation bumps on
every registration and every unregister_source, which is what makes a config
reload’s new user actions bindable with no restart. self._bindings never
changes under a live instance — a reload builds a new KeyBindings.
ACTIONSuser_api.load_user_entries(config) drops every source == "user" entry, clears
the hook table, and rebuilds both from the config. Running it is the reload
path, which is why the config carries no idempotence contract and no lifecycle
rules — the declarative shape is what buys that.
validate_user_entries(config) runs the identical scan with apply=False, so
ConfigManager.validate_config reports exactly the warnings a load would
produce without installing anything.
Both forms are accepted, mirroring KEY_BINDINGS’ own simple/extended duality:
ACTIONS = {
"select-docs": select_docs, # simple
"quit": {"func": noisy_quit, "override": True}, # extended
}
Extended keys: func, override, context, description.
invokeA name colliding with a built-in in the same context is refused — one warning,
entry skipped — unless override: True. ActionRegistry.register then files the
displaced built-in in _overridden rather than discarding it, so
registry.builtin() can still find it and unregister_source can put it back.
ctx.invoke(name) is XeFMApp._run_action, which resolves the user action
first. The re-entrancy guard is what makes wrapping work:
def noisy_quit(ctx):
ctx.message("bye!")
ctx.invoke("quit") # -> the built-in, not itself
_run_action adds the name to ActionContext._invoking before calling, so a
nested invoke of the same name sees it already running and falls through to
the handler table. A nested invoke of a different name runs that user action
normally.
A user action always reports “redraw needed”: it can change anything about the panes and has no way to say what it touched.
context defaults to "filer", and for this version a value other than
"filer" is a warning and a skipped entry. The design’s reasoning, unchanged:
a user function running inside a viewer needs a per-view API surface (scroll
position, block list, zoom) that has not been designed, and shipping half of one
would freeze it by accident. Rebinding a viewer’s built-in actions is
unaffected and fully supported. Widening later is additive — the registry
mechanics already allow it; only _build_action’s guard would move.
EVENT_HOOKSuser_api.hooks is an EventHooks holding {event: [callable]}. fire() runs
every hook for an event in order and returns whether any returned True.
Hooks behind one that raises still run, and a hook that claims an event does not suppress the ones after it — a claim decides what XeFM does next, not whether another hook’s side effect happens.
| Event | Fired from |
|---|---|
startup |
XeFMApp.run, after the first render |
quit |
XeFMApp._quit, before monitoring stops and state is saved |
directory_changed |
XeFMApp._fire_directory_changed, called from _list_pane |
file_open |
XeFMApp._open, before the dir / archive / file branch |
directory_changed’s choke pointEvery listing funnels through _list_pane — navigation, the O-sync that calls
it directly, a jump, a drive change, a monitor reload — which makes it the one
place that sees all of them. _fire_directory_changed compares the pane’s path
against pane["_hook_path"], the last path it reported, and fires only on a
difference. That is what keeps the event meaning “changed”: a post-operation
reload, a filesystem-monitor reload and a sort change all re-list the same
directory and stay silent.
_hook_path defaults to the pane’s current path on the first call, so the two
startup listings are silent — startup is the event for that. It is recorded
whether or not any hook is installed, so a hook added by a mid-session reload
starts from where the pane actually is instead of missing the first change.
file_openFires in _open for non-directories only, ahead of the archive / viewer branch.
Entering a directory is navigation, not opening. entry.is_dir() can raise on a
vanished or unreadable entry, so that probe is inside the same try/except the
open path already used.
xefm/user_api.py contains no PuiKit type, no widget, and no XeFMApp
internal in any signature. It is the firewall that lets the internals keep
moving: PaneApi is a model-level view of one pane, holding the app and the
pane’s name — not the pane dict — so it stays valid across a re-list that
replaces the listing wholesale.
EntryInfo reads is_dir / is_link from the pane’s existing file_info
cache, which costs nothing, and stats lazily for size / mtime, which the
cache does not hold (it keeps them pre-formatted for display). A name-only
predicate over a large directory therefore touches the filesystem not at all.
The design sketched ctx.input(prompt) -> str | None. XeFM has no blocking
modal to build that on — every dialog is a layer pushed onto the panel with an
on_result callback, and the event loop keeps running. input, choose and
confirm therefore take callbacks and return None. The alternative would have
been a nested event loop, which is a much larger commitment than a preview API
should make.
run_program shares the picker’s launcherctx.run_program() is the PROGRAMS picker’s launch path with the argv
handed in rather than built from the selection (#454). XeFMApp._run_program
was split in two for it: _program_env(pane) builds the environment and the
working directory (including the search-results pane’s absolute-path spelling
of XEFM_THIS_*), and _launch_program(name, argv, cwd, env, terminal) does
the launching — _run_in_terminal (suspend, wait, re-list both panes) for
terminal=True, Popen + _watch_program streaming into the log queue
otherwise. The picker and the action both go through the two, so a program
cannot see a different contract depending on how it was started.
The API exists because the obvious alternative, subprocess.run() from inside
an action, is wrong in terminal mode: the child and PuiKit’s input thread share
the tty, so the child’s output lands on a screen XeFM still believes it owns,
and keystrokes are split between the two — the report in #454 was a garbled
screen after vim, and a hang when the child sat waiting on a key XeFM had
already consumed. The suspend/resume that fixes it is a backend detail the
façade may not expose, so the façade exposes the launch instead.
Two deliberate differences from a PROGRAMS entry: no file names are appended
(the action has the selection and builds its own argv), and the default cwd
falls back to XeFM’s own when the pane is not on the local disk, since an
s3:// path is no working directory. _run_in_terminal now returns the exit
code so terminal=True can hand it back.
run_guarded is the single crossing into user code — actions, hooks and dialog
callbacks all go through it. It logs the exception and a formatted traceback and
returns None. _guard wraps dialog callbacks specifically, because an
exception escaping into the dialog machinery would leave a modal layer stuck
open with no way to dismiss it.
Naming every key made the names already there worth auditing. Nineteen were
changed. Every old spelling survives as an entry in Action.aliases, so a config
binding one still resolves to the action — that is the only thing making a
rename possible at all, since every config generated from the template carries
the nine image_* names.
The user-facing table is in
KEY_BINDINGS_FEATURE.md;
test_customization_api.py’s SHIPPED_RENAMES is the authoritative list and is
parameterized over, so an alias cannot be dropped by accident.
Three groups:
The image viewer’s actions gained their viewer’s prefix. image_zoom_in →
image_viewer.zoom_in. That family was the dotted convention already, spelled
with an underscore, and the rename leaves the plain zoom_in / next free to
become shared actions if a second viewer grows them.
The text viewer’s toggle_wrap, toggle_view_mode and change_encoding were
not renamed, and the reason is worth recording because the first pass got it
wrong. Qualifying them would have made the bare name a permanent alias — and an
alias can never become a current name again (test_no_alias_collides_with_a_current_name),
so text_viewer.change_encoding would have spent change_encoding, which is
exactly the name the rule existed to preserve. The image_* renames do not have
this problem: their aliases are image_zoom_in, not zoom_in.
The concern is concrete rather than theoretical. DiffViewer already reads
through text_viewer._read_lines, which already takes an encoding override
and is called there with the detected value discarded — adding the picker is
small, and it would want the same binding. toggle_wrap is further along still:
TextViewer already forwards it to a rich renderer with its own wrap
(JsonView).
image_scroll_* became image_viewer.pan_*. The code is _pan_by, the
help says “pan”, and the keys move a viewport over a zoomed image. Only the name
was ever wrong.
Three unrelated things called “search” got names that distinguish them.
search → isearch (jump to a match as you type), search_dialog →
find_files, search_content → find_in_files. Alongside that, sort_menu →
sort (it opens a dialog; there is no menu), drives_dialog → drives
(dropping a suffix that named the widget, which favorites/history/programs
never had), rename_file → rename (it batch-renames a multi-selection), and
the four selection toggles → toggle_select_*, which separates them from
select_all / unselect_all — those set and clear outright rather than
toggling, and select_all sitting one character from select_all_items hid
that.
The file list’s other 60-odd actions were left alone. filer is the default
context and the namespace’s incumbent; prefixing cursor_up would churn every
config alive to say what the absence of a dot already says.
ActionRegistry.canonical(context, name) returns the current name for either
spelling, backed by a per-context alias index rebuilt on generation change.
_context_entries runs its config pass twice — current names first, then
aliases — so a config carrying both spellings of an action resolves to the
current one rather than to whichever it happened to list first; the stale entry
is simply not reached. _context_binding walks the same order for one name.
config.deprecated_names_notice() produces the nudge, as one line however
many names are involved: a config predating several renames would otherwise open
every session with a wall of warnings about bindings that all still work. A name
the config spells both ways is not reported — the current spelling already wins,
and telling someone to rename what they have already renamed is noise.
Aliases are permanent, and an alias may never be reused for a different action —
test_no_alias_collides_with_a_current_name enforces the second half.
Four, all deliberate:
Nineteen actions were renamed, old names kept as aliases. See “Renamed actions” above.
Viewer keys are rebindable. The visible win of the refactor.
A key bound to a viewer action no longer fires in the file list, and vice versa. Previously the flat lookup could return either, decided by dict order. With the shipped template the outcome is identical (verified by test); a user config whose dict order differed could see a change, always in the direction of “the key now does what that surface’s action says”.
A config predating a viewer action now gets that action’s current default,
not the viewer’s old hardcoded fallback. ImageViewer used to fall back to
n/p for stepping and plain arrows for panning when those actions were
absent from KEY_BINDINGS; it now falls back to the registry defaults
— the same keys a freshly written config gets.
Such a config converges on the documented defaults instead of keeping
pre-release ones. test_image_viewer.py’s legacy_config tests assert the
new behavior.
API_VERSION = 0 and PREVIEW = True in xefm/user_api.py. The gate is a
notice, not a switch: a config using either variable gets one line in the log
pane at load and at every reload —
Customization API (Preview, API_VERSION 0): 2 action(s), 1 event hook(s) loaded
— this API may change without notice.
preview_notice() returns None when every count is zero, so a config that
does not use the API says nothing; the registries that landed later add their own
clause (3 sort key(s), 2 filter(s)) only when a config defines any.
Reaching 1 means committing to ActionContext, PaneApi, EntryInfo and the
config variable formats. Before that, the questions worth settling with real
use: whether user functions should be allowed in viewer contexts and what a
per-view API looks like; whether the callback-shaped prompts are livable or want
a different idiom; whether directory_changed should also fire on entering and
leaving a search-results (virtual) pane.
SORT_KEYS — the first exposed registryStep 4’s first customer, and deliberately not the one the design wrote up first.
VIEWER_RENDERERS returns a PuiKit widget, the one place a PuiKit type must
cross the façade; a sort key returns data, so it shook the machinery down
without freezing anything hard to keep stable. It ships because #380 asked for
it: XeFM’s order is not the platform shell’s, and the answer is to expose the
choice rather than build one shell’s collation in.
xefm/sort_keys.py holds the table — the four built-in rows, and whatever a
config registered, rebuilt wholesale on reload exactly as EVENT_HOOKS is.
The built-in modes were renamed to the dialog’s own labels in lower case
(filename / extension / size / timestamp) as part of this. XeFM’s
shorthand — name, ext, date — cost nothing while it stayed internal and
started costing something the moment a config had to write one: a user reads
“Timestamp” in the dialog and has no way to guess date. sort_keys.ALIASES
keeps the old spellings resolving, and sort_keys.canonical is applied wherever
a mode arrives from outside — saved pane state, DEFAULT_SORT_MODE, a SORT_KEYS
key, an action — so everything past that boundary compares one set of names. The
quick_sort_* action names are untouched: those live in the key-binding
namespace, which has its own rename mechanism (Action.aliases).
_process_user_entries validates and loads it alongside the other two, and
_build_sort_key mirrors _build_action: a bare callable is shorthand, a dict
carries label / explain / hotkey, and naming a built-in needs
"override": True for the same reason an action does.
Four decisions worth keeping:
Key functions, not comparators. A key is called O(N) times, a comparator
O(N log N). That was a live tension while platform collation was still the plan
for #380 — the native routes (CompareStringW, localizedStandardCompare:) are
comparators, so a built-in would have wanted one contract and the registry
another. Exposing the choice instead of building it in removed the tension: there
is one contract, and a config that insists on a comparator wraps it in
functools.cmp_to_key and owns the cost.
The key runs off the UI thread, which is why _resort moved to a worker
(see ASYNC_LISTING_SYSTEM.md). This is the opposite
of the promise ACTIONS and EVENT_HOOKS make, and it is the promise §6’s
threading rule always said had to be made with the first registry rather than
after: sorting is the slow per-entry work, so it is the one thing that cannot
be pinned to the UI thread.
EntryInfo.from_attrs seeds size and mtime from the listing’s own attribute
record. Without it a key reading entry.size would stat once per entry — 10,000
round trips on a network mount, for information the listing already collected,
behind an ordering that needs none.
Failure is isolated at the sort, not the entry. run_guarded’s per-call
granularity is right for an action and wrong here: a key that is broken is broken
for every file, and would emit a traceback per row. _sort_by_user_key wraps the
whole sort, falls back to the built-in name order, and logs once.
Integration is wider than the table: a sort mode is a string that reaches the
dialog’s rows and explanations, the Sort By menu, the status-bar description, and
state_manager’s saved pane state — where sort_keys.is_known guards the
restore, because a config can stop defining a mode a previous session saved.
FILTERS — the second one, and the first with no built-in tableSORT_KEYS exposed a choice XeFM already made four ways; FILTERS exposes one
it made one way — the pane filter has been a single fnmatch pattern since the
beginning. So there is no BUILTIN tuple here, no ALIASES, and no override:
a filter name collides with nothing, because nothing is registered until a config
registers it.
xefm/filters.py holds the table and, more importantly, matcher() — the single
place that decides whether a string is a name or a pattern. That question
is the whole design. pane['filter_pattern'] is one string that reaches the
status bar, the saved pane state, the picker’s history and the content search’s
narrowing; making a registered filter a second kind of value there would have
touched every one of those. Instead the registry is asked first and fnmatch is
the fallback, so the pane state stays a string and every existing caller keeps
working — the change to _assemble_listing is one line, and the content search’s
walk gained a matcher where it had a glob.
The cost of that choice is one rule: a filter name may not contain *, ? or
[, rejected at load with a warning. Without it a config could define "*.py"
and the picker could no longer tell a defined filter from a pattern someone
typed — the ambiguity would be unresolvable rather than merely surprising.
Three decisions worth keeping:
Two spellings, one shape. A pattern entry compiles to a closure over
fnmatch and never builds an EntryInfo; a match entry gets one per file, as
the sort does. The glob case is the common one and stays exactly as cheap as the
built-in filter it generalizes, which is what makes “define the filters you keep
typing” a free convenience rather than a trade.
Failure fails open. _sort_by_user_key falls back to a different order;
_apply_filter_pattern falls back to no filtering, logs once, and shows every
entry. A broken sort shows you the same files in the wrong order; a broken filter
would hide files — and a hidden file is one a user can neither see nor stop from
being caught in the next operation. The safe direction is the visible one.
Defined filters are not history. _record_filter_pattern refuses a name the
registry knows. The picker’s three bands — clear, defined, remembered — each mean
something different, and a defined filter that also aged through the history
would appear twice and evict a real typed pattern to do it. It is the same
reasoning that keeps isearch patterns out of that history (#385).
Directories are still shown unconditionally, exactly as under a typed glob: the
rule protects navigation (the subdirectory you were about to enter cannot vanish
because of a filter), and it makes “directories only” expressible as
lambda e: False while leaving “files only” out of reach — the deliberate
asymmetry.
IMAGE_DECODERS — the third, and the first where the registry is not the pointxefm/image_decoders.py. Written up in full in
doc/dev/IMAGE_VIEWER_IMPLEMENTATION.md; what belongs here is what it did to the
machinery and what it settled.
It rode the rails unchanged. _build_image_decoder sits beside
_build_sort_key and _build_filter; one more loop in _process_user_entries,
one more clause in preview_notice, one more clear() in the reload path. The
“build one mechanism, not ten register_* functions” bet from discussion #378 §5
held for the third table running, and the whole of the config-side work was under
fifty lines.
The interesting part was elsewhere. Unlike SORT_KEYS and FILTERS, this
registry was not the answer on its own. A decoder returns pixels, and until
PuiKit’s draw_image accepted pixels (RasterImage, puikit 1.7.0) the only way
to hand them over was to write a PNG back out and pass its path — an encode and a
decode to move data the backend was about to be given. So the registry arrived
with three PuiKit pieces under it: RasterImage, Backend.image_formats(), and
each GUI backend answering image_size() through its own decoder.
That last one is the part that generalizes. Exposing a registry is not the same
as knowing when to use it, and image_formats() is what answers the second
question: the app decodes a format only when the backend cannot, which on macOS
means HEIC and camera RAW cost nothing at all. A registry with no such answer
would have made every picture take the slow path.
Three decisions worth keeping:
No override. A sort key or an action can shadow a built-in by accident, so
both ask for {'override': True}. A decoder cannot: registering one for a format
XeFM already reads is the only way to replace how it is read, and there is no
other reason to write the entry. Asking twice would be asking about the thing the
entry already says.
Failure fails closed. A sort key that fails falls back to a different order; a filter that fails shows everything. A decoder that fails shows no picture — the card names the reason and the log takes the traceback. Failing open has no meaning here: there is no honest stand-in for a picture that would not decode. Confirms §8c’s point that the safe direction is decided per registry rather than inherited.
Registering claims the format. claimed_suffixes() folds the registered
suffixes in, so IMAGE_DECODERS is the whole of “make .svg open in the image
viewer” — the user never edits a second list. The general shape: a registry whose
entries also widen what the feature applies to is worth more than one that only
changes how it behaves.
Step 4’s remainder. VIEWER_RENDERERS feeding viewer_registry.register().
xefm/viewer_registry.py still anticipates it in its own docstring. The design flags the reason to wait: a
renderer builder returns a PuiKit widget, which is the one place a PuiKit type
must cross the façade, and that is the hardest part of this API to keep stable
across PuiKit releases.
Add-ons (~/.xefm/addons/). Out of scope by design — the same machinery
plus discovery and lifecycle, and a stability promise to third parties that
should wait until this API has survived a few releases of real config-level use.
A command palette. The registry makes it straightforward (fuzzy-run any action of the active context by name) and it is the obvious next thing to build on top, but it is a feature, not part of this API.
CUSTOMIZATION_API_DESIGN.mdKEY_BINDINGS_IMPLEMENTATION.mdCONFIGURATION_SYSTEM.mdtest/test_customization_api.py