splent_feature_search
Search across a whole product, over material this feature knows nothing about.
A SPLENT product is assembled from features, and the material worth searching belongs to them: pages to courses, articles to post, people to team. This feature owns the search page, the index and the ranking, and owns no content at all. A content feature makes itself searchable by registering three callables with the framework, and a search written today keeps working with a content feature written next year. Neither imports the other.
The seam
splent_framework.search.search_registry is the whole contract. A content
feature calls this once, from init_feature:
from splent_framework.search.search_registry import register_search_source
def init_feature(app):
register_search_source(
key="courses",
label="Pages",
fetch=fetch_pages,
resolve=resolve_page,
find=find_pages, # optional
scopes=course_scopes, # optional
current_scope=course_of_url, # optional
order=100,
)
fetch() yields every document, for building an index. Each one is
{"id": str, "title": str, "body": str} and may carry "url", "scope" and
any extra field the feature wants stored.
resolve(doc_id, user) is the authority. Given one candidate and the reader
asking, it returns a presentable result, {"title": ..., "url": ..., "snippet": ...}, or None to drop it. It is called once per candidate on
every search, so it should be a primary key lookup and not a scan.
find(term, user, scope=None) is optional and is how the feature searches
itself when no engine is reachable. Same return shape as resolve. A source
that omits it contributes nothing while the engine is down, which is a decision
the feature gets to make. When a scope arrives it has to be honoured: without an
index there is nothing else between the reader and the whole site.
order sorts the groups on the results page. label names the group and is
translated at render time, so pass a plain or a lazy string, never one already
translated at startup.
Narrowing
A wiki holding fourteen academic years has a page called "Lab 5" in every one of them, so "everywhere" is the least useful answer the box can give. Two more optional callables turn one box into "search this course", without this feature ever learning what a course is.
scopes(user) returns the places this reader may narrow to, as
[{"key": "egc-20252026", "label": "EGC 2025/2026"}, ...] in display order,
already filtered to what they may be told exists. The list is rendered in a
selector, so a course that has not been announced is a course whose name the
selector would announce.
current_scope() returns where the reader is standing right now, read out of
the request, or None. It belongs to the feature that owns those URLs. Only
courses knows that the second segment of /cursos/egc-20252026/paginas/lab-5
names a course, and a search feature taking the URL apart itself would be
guessing at another feature's routing, would guess wrong the day the product
serves it under a different word, and would have to guess again for every
content feature ever written.
A document carries "scope" with the key of the place it belongs to. It is
indexed as a keyword, matched whole and never analysed, and a narrowed search
filters on it. A document with no scope belongs nowhere in particular and does
not appear in a narrowed search.
A scope is applied as it arrives and is never checked against the list first. A name nobody offers matches no document and answers nothing, which is what a stale bookmark or an edited address deserves; checking it and falling back would turn that same request into a search over the whole site, which is the one answer nobody asked for. Nothing leaks either way, because every candidate is still resolved by the feature that owns it.
The box in the header defaults to current_scope() and lets the reader switch
to everything or to another place. On the search page, the scope the page
searched wins, so the two boxes never disagree. An empty scope= means
everything and is not the same as no scope= at all, which means wherever the
reader is.
Why the split
An index is a cache and is never the authority on visibility.
It is a copy taken at some moment in the past, and material is withheld between
one reindex and the next. If a search answered out of the index, the first
reader to type "exam" after an exam was pulled would be shown its title, and a
title gives away as much as a body does. So Elasticsearch is only ever allowed
to propose candidates: the query asks for ids and switches _source off, and
every id goes back to the feature that owns it through
resolve_search_hit(key, doc_id, user), at request time, with the reader in
hand. Nothing stored in the index is ever rendered, not even the title.
Deny by default, exactly like the file access registry. A source nobody registered, a resolver that raises, a result with no URL: each one drops its candidate and leaves the rest of the search alone.
The other half of the rule is what never goes into an index. Documents carry no visibility, and the registry says so. Recording who may read what would be wrong the moment somebody withheld a page, and it would be answering a question about a reader who is not there yet and will be somebody else tomorrow.
What a product gets
GET /<SEARCH_PATH>(search.index), a server-rendered page. It is a plain GET form and it works with JavaScript switched off. A term shorter thanSEARCH_MIN_CHARSrenders the empty state, not an error.GET /<SEARCH_PATH>/results.json(search.results), the same answer as JSON, for the script that makes the page feel immediate.- A search box in
layout.nav, unlessSEARCH_NAVis off. It answers over AJAX into a panel under the box, with the arrow keys moving, Enter opening, Escape closing, and a plain form submit when there is no script. Nothing in that panel is written withinnerHTML. SearchServicein the service locator, withquery(),scopes(),reindex()andindex_report().splent feature:search reindex [--source KEY]andsplent feature:search status.
The JSON answer:
{
"query": "grafo",
"engine": "elasticsearch",
"scope": "egc-20252026",
"scopes": [
{"key": "", "label": "Everything"},
{"key": "egc-20252026", "label": "EGC 2025/2026"}
],
"total": 12,
"groups": [
{"key": "courses", "label": "Pages",
"results": [{"title": "...", "url": "/cursos/...", "snippet": "..."}]}
]
}
engine says how the answer was produced: elasticsearch from the index,
fallback from the sources' own find(), none when nothing produced it. The
page says so too, in a sentence, rather than letting a thin answer look like a
site with nothing in it.
scope is the narrowing that was applied, or null. scopes is what the
selector offers, always including the everything option under the empty key, and
it travels with every answer because the box that asked the question is redrawn
from it.
The engine is optional
splent_feature_elasticsearch provides the transport. A product may install
search without it, and a product that installed it may be running while the
node is down. Both end up on the fallback, and the answer is labelled either
way. The contract names elasticsearch under requires.features because
services.py imports its exception types and the contract scanner reads
dependencies out of import statements; the import itself is guarded and the
transport is reached by name through the service locator.
One index per source, named <ELASTICSEARCH_INDEX_PREFIX>_<source_key>. The
prefix belongs to the engine feature, which keeps two products sharing a node
out of each other's documents, so everything here passes the bare source key
and lets the engine put it in the right namespace.
reindex drops each index and builds it again, because writing over it leaves
behind every document the feature no longer has. The window in which an index
is incomplete costs results and never privacy, which is the only direction that
is safe to be wrong in.
Configuration
| Env var | Default | Purpose |
|---|---|---|
SEARCH_PATH |
search |
The word the search is served under. It ends up in links people send each other. |
SEARCH_LIMIT |
20 |
Candidates taken from each source, per source rather than in total. |
SEARCH_MIN_CHARS |
2 |
Below this a term is somebody still typing, not a question. |
SEARCH_NAV |
true |
Whether the public header carries a search box. |
Tests
splent feature:test splent_feature_search
The sources in the tests are fakes registered through the framework registry, so nothing here depends on a content feature being installed, and the engine is a fake index that proposes exactly what a test put in it, including documents the resolver then refuses.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file splent_feature_search-0.3.1.tar.gz.
File metadata
- Download URL: splent_feature_search-0.3.1.tar.gz
- Upload date:
- Size: 34.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
97f214d4aae3a928284e507f884aa19a87179f227b191a3068d3e6d7256aa720
|
|
| MD5 |
f58aebfb25d63078c15a4d5d7aed85e3
|
|
| BLAKE2b-256 |
deefd19aff09e05f1b02a6352c0b5f1718f712111ec976aa32a6917a7e39a0b3
|
File details
Details for the file splent_feature_search-0.3.1-py3-none-any.whl.
File metadata
- Download URL: splent_feature_search-0.3.1-py3-none-any.whl
- Upload date:
- Size: 40.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e443dda7f311da5b01f69904fbe6a91754bdc3154f92466fed7c5285c497aece
|
|
| MD5 |
63f760e79cf95d703fffb94fbd2d1268
|
|
| BLAKE2b-256 |
394375e652aec24feb90f0a3d472329abd32c8c8d629b305dcab5cb939bbfc77
|