pyecos 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyecos/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """pyecos -- a Python client for the Bank of Korea ECOS Open API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from . import catalog
8
+ from .client import ECOS
9
+ from .curation import Indicator, IndicatorSpec
10
+ from .exceptions import (
11
+ ECOSAuthError,
12
+ ECOSConfigError,
13
+ ECOSError,
14
+ ECOSNetworkError,
15
+ ECOSRateLimitError,
16
+ ECOSResponseError,
17
+ )
18
+ from .types import (
19
+ CatalogRow,
20
+ Cycle,
21
+ ItemRow,
22
+ KeyStatRow,
23
+ Language,
24
+ MetaRow,
25
+ StatRow,
26
+ TableRow,
27
+ WordRow,
28
+ )
29
+
30
+ try:
31
+ __version__ = version("pyecos")
32
+ except PackageNotFoundError: # running from a source tree without an install
33
+ __version__ = "0.0.0"
34
+
35
+ __all__ = [
36
+ "ECOS",
37
+ "catalog",
38
+ "Indicator",
39
+ "IndicatorSpec",
40
+ "Cycle",
41
+ "Language",
42
+ "StatRow",
43
+ "CatalogRow",
44
+ "TableRow",
45
+ "ItemRow",
46
+ "KeyStatRow",
47
+ "WordRow",
48
+ "MetaRow",
49
+ "ECOSError",
50
+ "ECOSConfigError",
51
+ "ECOSAuthError",
52
+ "ECOSResponseError",
53
+ "ECOSRateLimitError",
54
+ "ECOSNetworkError",
55
+ "__version__",
56
+ ]
pyecos/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run the pyecos CLI, so ``python -m pyecos`` matches the ``pyecos`` console script.
2
+
3
+ Importing this module runs the CLI and terminates the process via ``SystemExit``.
4
+ """
5
+
6
+ from .cli import main
7
+
8
+ raise SystemExit(main())
pyecos/_cache.py ADDED
@@ -0,0 +1,75 @@
1
+ """An opt-in, TTL-bounded store of prior fetch results, keyed by the logical query.
2
+
3
+ Off unless :class:`ECOS` is given a ``cache_ttl``. When on, a repeated query returns
4
+ the stored rows without a network round trip -- cutting calls so a burst stays under
5
+ the ECOS rate cap (~300 in three minutes), and repeating work for free.
6
+
7
+ The store holds *complete* results (a whole paginated, mapped series), never a single
8
+ page: a page is a partial intermediate, and caching answers rather than ingredients is
9
+ what keeps a cache honest. Entries expire after ``ttl`` seconds, so the staleness a
10
+ caller accepts is exactly the bound they chose -- a series whose latest observation has
11
+ since updated is re-fetched once its entry expires. Least-recently-used entries are
12
+ evicted past ``maxsize`` so the store cannot grow without bound.
13
+
14
+ Each entry is copied on store and on retrieval, row by row, so a caller mutating a
15
+ returned row cannot corrupt the stored entry or another caller's copy; the rows carry
16
+ only scalar values, so a per-row ``dict`` copy fully isolates them.
17
+
18
+ Not thread-safe: the entries are mutable instance state with no lock, like the client
19
+ that owns it -- use one client per thread.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import time
25
+ from collections import OrderedDict
26
+ from typing import Any
27
+
28
+ # (service, resolved language, request tail) -- the logical query, minus the paging
29
+ # window and the API key, so kr and en cache apart and two clients never collide.
30
+ _CacheKey = tuple[str, str, tuple[str, ...]]
31
+
32
+ _Rows = list[dict[str, Any]]
33
+
34
+ _DEFAULT_MAXSIZE = 256
35
+
36
+
37
+ def _isolate_rows(rows: _Rows) -> _Rows:
38
+ """A copy of ``rows`` sharing none of its dicts (values are scalars)."""
39
+ return [dict(row) for row in rows]
40
+
41
+
42
+ class _Cache:
43
+ """A TTL + LRU store mapping a query key to its rows."""
44
+
45
+ def __init__(self, *, ttl: float, maxsize: int = _DEFAULT_MAXSIZE) -> None:
46
+ self._ttl = ttl
47
+ self._maxsize = maxsize
48
+ self._entries: OrderedDict[_CacheKey, tuple[float, _Rows]] = OrderedDict()
49
+
50
+ def get(self, key: _CacheKey) -> _Rows | None:
51
+ """The cached rows for ``key`` if present and unexpired, else ``None``.
52
+
53
+ Returns an isolated copy, so a caller mutating the rows cannot corrupt the
54
+ entry.
55
+ """
56
+ entry = self._entries.get(key)
57
+ if entry is None:
58
+ return None
59
+ expires_at, rows = entry
60
+ if time.monotonic() >= expires_at:
61
+ del self._entries[key]
62
+ return None
63
+ self._entries.move_to_end(key) # mark most-recently-used
64
+ return _isolate_rows(rows)
65
+
66
+ def set(self, key: _CacheKey, rows: _Rows) -> None:
67
+ """Store an isolated copy of ``rows``, evicting the LRU entry past maxsize."""
68
+ self._entries[key] = (time.monotonic() + self._ttl, _isolate_rows(rows))
69
+ self._entries.move_to_end(key)
70
+ while len(self._entries) > self._maxsize:
71
+ self._entries.popitem(last=False) # drop the least-recently-used
72
+
73
+ def clear(self) -> None:
74
+ """Drop every entry."""
75
+ self._entries.clear()
pyecos/_config.py ADDED
@@ -0,0 +1,63 @@
1
+ """Resolve the ECOS API key from the caller, the environment, or the config file.
2
+
3
+ The key is looked up in a fixed order, so an explicit value always wins and a set
4
+ environment variable beats a file on disk:
5
+
6
+ 1. the ``api_key`` passed to ``ECOS(...)``
7
+ 2. the ``ECOS_API_KEY`` environment variable
8
+ 3. ``"ECOS_API_KEY"`` in ``$XDG_CONFIG_HOME/pyecos/credentials.json``
9
+ (``$XDG_CONFIG_HOME`` defaults to ``~/.config``)
10
+
11
+ The file is optional -- its absence just means "no key here." But a file that is
12
+ present and unreadable, not JSON, or not a JSON object is an error, because a caller
13
+ who wrote one meant it to be used and a silent skip would hide the mistake.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+
22
+ from .exceptions import ECOSConfigError
23
+
24
+ _ENV_VAR = "ECOS_API_KEY"
25
+ _CONFIG_DIR = "pyecos"
26
+ _CONFIG_FILE = "credentials.json"
27
+
28
+
29
+ def resolve_api_key(explicit: str | None) -> str:
30
+ """Return the first key found across the three sources, or raise if none exists."""
31
+ key = explicit or os.environ.get(_ENV_VAR) or _key_from_file()
32
+ if not key:
33
+ raise ECOSConfigError(
34
+ f"no ECOS API key: pass api_key=, set the {_ENV_VAR} environment "
35
+ f"variable, or put it in {credentials_path()}"
36
+ )
37
+ return key
38
+
39
+
40
+ def credentials_path() -> Path:
41
+ """The path pyecos reads a stored key from (honoring ``$XDG_CONFIG_HOME``)."""
42
+ config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
43
+ return Path(config_home) / _CONFIG_DIR / _CONFIG_FILE
44
+
45
+
46
+ def _key_from_file() -> str | None:
47
+ path = credentials_path()
48
+ try:
49
+ text = path.read_text(encoding="utf-8")
50
+ except FileNotFoundError:
51
+ return None
52
+ except OSError as err:
53
+ raise ECOSConfigError(f"could not read {path}: {err}") from err
54
+
55
+ try:
56
+ data = json.loads(text)
57
+ except json.JSONDecodeError as err:
58
+ raise ECOSConfigError(f"{path} is not valid JSON: {err}") from err
59
+ if not isinstance(data, dict):
60
+ raise ECOSConfigError(f"{path} must contain a JSON object")
61
+
62
+ key = data.get(_ENV_VAR)
63
+ return key if isinstance(key, str) and key else None
pyecos/_parse.py ADDED
@@ -0,0 +1,110 @@
1
+ """Turn ECOS's paged, upper-cased rows into a full list of snake_cased dicts.
2
+
3
+ Two jobs live here, both above a single wire request (``_transport``):
4
+
5
+ * **paginate** -- ECOS caps a response at :data:`PAGE_SIZE` rows, so walk the
6
+ ``start_row``/``end_row`` window until ``list_total_count`` is exhausted and
7
+ return every row, never a first page silently mistaken for the whole answer.
8
+ * **map** -- rename the vendor's ``UPPER_CASE`` keys to the snake_case fields the
9
+ ``TypedDict``s document, and parse the one genuinely numeric field
10
+ (``DATA_VALUE``) to ``float``. Every key the vendor sends is kept; an
11
+ unrecognized one simply lowercases and passes through.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from ._transport import PAGE_SIZE, _Transport
19
+ from .exceptions import ECOSResponseError
20
+
21
+ # No real ECOS series approaches a million rows; the cap turns a broken server
22
+ # that returns full pages forever into a clear error instead of an endless loop.
23
+ _MAX_PAGES = 10_000
24
+
25
+ # Vendor keys whose snake_case is not a plain lowercase of the original.
26
+ _FIELD_BY_VENDOR_KEY = {
27
+ "P_STAT_CODE": "parent_stat_code",
28
+ "P_ITEM_CODE": "parent_item_code",
29
+ "P_ITEM_NAME": "parent_item_name",
30
+ "GRP_CODE": "group_code",
31
+ "GRP_NAME": "group_name",
32
+ "ORG_NAME": "org_name",
33
+ "SRCH_YN": "searchable",
34
+ "WGT": "weight",
35
+ "DATA_CNT": "data_count",
36
+ "DATA_VALUE": "data_value",
37
+ "KEYSTAT_NAME": "keystat_name",
38
+ "CLASS_NAME": "class_name",
39
+ "LVL": "level",
40
+ "P_CONT_CODE": "parent_content_code",
41
+ "CONT_CODE": "content_code",
42
+ "CONT_NAME": "content_name",
43
+ }
44
+
45
+
46
+ def collect(
47
+ transport: _Transport,
48
+ *,
49
+ service: str,
50
+ api_key: str,
51
+ lang: str,
52
+ tail: list[str],
53
+ ) -> list[dict[str, Any]]:
54
+ """Fetch every page of a service call and return the mapped rows."""
55
+ return [_map_row(row) for row in _paginate(
56
+ transport, service=service, api_key=api_key, lang=lang, tail=tail,
57
+ )]
58
+
59
+
60
+ def _paginate(
61
+ transport: _Transport,
62
+ *,
63
+ service: str,
64
+ api_key: str,
65
+ lang: str,
66
+ tail: list[str],
67
+ ) -> list[dict[str, Any]]:
68
+ rows: list[dict[str, Any]] = []
69
+ start_row = 1
70
+ for _ in range(_MAX_PAGES):
71
+ page = transport.request_page(
72
+ service=service,
73
+ api_key=api_key,
74
+ lang=lang,
75
+ start_row=start_row,
76
+ end_row=start_row + PAGE_SIZE - 1,
77
+ tail=tail,
78
+ )
79
+ batch = page.get("row") or []
80
+ rows.extend(batch)
81
+ try:
82
+ total = int(page.get("list_total_count") or 0)
83
+ except (TypeError, ValueError):
84
+ # A garbage total must not truncate the series: push it past what we
85
+ # have so the loop keeps paging and the empty-batch guard ends it.
86
+ total = len(rows) + PAGE_SIZE
87
+ if not batch or len(rows) >= total:
88
+ return rows
89
+ start_row += PAGE_SIZE
90
+ raise ECOSResponseError("UNKNOWN", f"pagination exceeded {_MAX_PAGES} pages")
91
+
92
+
93
+ def _map_row(raw: dict[str, Any]) -> dict[str, Any]:
94
+ row = {_FIELD_BY_VENDOR_KEY.get(key, key.lower()): value
95
+ for key, value in raw.items()}
96
+ if "data_value" in row:
97
+ row["data_value"] = _to_float(row["data_value"])
98
+ return row
99
+
100
+
101
+ def _to_float(text: Any) -> float | None:
102
+ # ECOS marks a missing observation with an empty string or a lone dash; any
103
+ # other unparseable value also becomes None (the vendor sends plain numbers,
104
+ # so this only fires on genuinely malformed data, reported as missing).
105
+ if text in (None, "", "-"):
106
+ return None
107
+ try:
108
+ return float(text)
109
+ except (TypeError, ValueError):
110
+ return None
pyecos/_transport.py ADDED
@@ -0,0 +1,176 @@
1
+ """One request over the wire: build the ECOS path, GET it, surface errors.
2
+
3
+ ECOS is a path-positional API -- every argument is a slash segment in a fixed
4
+ order, up to fifteen of them:
5
+
6
+ /api/{service}/{key}/{format}/{lang}/{start_row}/{end_row}/{tail...}
7
+
8
+ This module is the only place that order is written down. ``_Transport`` holds the
9
+ HTTP client and the pacing clock: it spaces consecutive requests (``delay_seconds``)
10
+ and retries a transient failure (timeout, connection reset, 5xx) with backoff, but a
11
+ rate limit (ERROR-602) is an answer to respect, raised straight through.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from typing import Any
19
+ from urllib.parse import quote
20
+
21
+ import httpx
22
+
23
+ from .exceptions import (
24
+ ECOSAuthError,
25
+ ECOSNetworkError,
26
+ ECOSRateLimitError,
27
+ ECOSResponseError,
28
+ )
29
+
30
+ BASE_URL = "https://ecos.bok.or.kr/api"
31
+
32
+ # ECOS serves at most this many rows per request; the parser pages past it.
33
+ PAGE_SIZE = 100
34
+
35
+ # The vendor's rate-limit code -- in the RESULT body, and mirrored for an HTTP 429.
36
+ _RATE_LIMIT_CODE = "ERROR-602"
37
+
38
+ # A transient failure (timeout, reset, 5xx) is a glitch worth retrying; a rate
39
+ # limit is not (see _Transport.request_page). This counts total attempts, not
40
+ # retries -- 3 is one try plus two retries.
41
+ _MAX_ATTEMPTS = 3
42
+ _RETRY_BACKOFF_SECONDS = 1.0
43
+ _RETRY_BACKOFF_FACTOR = 2 # each retry waits this many times the last
44
+
45
+
46
+ class _Transport:
47
+ """The HTTP client plus its pacing clock -- one per :class:`ECOS`.
48
+
49
+ ``delay_seconds`` spaces consecutive requests so a burst (a long paginated
50
+ series, or many indicators in a loop) stays under the ECOS rate cap of ~300
51
+ calls in three minutes; the default is 0 because a handful of calls never
52
+ reaches it, and pacing every page would only slow the common case. A bulk
53
+ caller sets it (0.6s keeps one client under the cap indefinitely).
54
+
55
+ Not thread-safe: the pacing clock (``_next_request_at``) is shared mutable
56
+ state, so use one client -- hence one transport -- per thread.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ client: httpx.Client,
62
+ *,
63
+ delay_seconds: float = 0.0,
64
+ max_attempts: int = _MAX_ATTEMPTS,
65
+ ) -> None:
66
+ self._client = client
67
+ self._delay_seconds = delay_seconds
68
+ self._max_attempts = max_attempts
69
+ self._next_request_at = 0.0
70
+
71
+ def request_page(
72
+ self,
73
+ *,
74
+ service: str,
75
+ api_key: str,
76
+ lang: str,
77
+ start_row: int,
78
+ end_row: int,
79
+ tail: list[str],
80
+ ) -> dict[str, Any]:
81
+ """Fetch one page and return its ``{list_total_count, row}`` body.
82
+
83
+ Retries a transient transport failure (timeout, connection reset, 5xx) with
84
+ backoff. Raises :class:`ECOSNetworkError` if it never completes,
85
+ :class:`ECOSRateLimitError` on a rate limit (ERROR-602, not retried),
86
+ :class:`ECOSAuthError` on a rejected key, and :class:`ECOSResponseError` on
87
+ any other vendor error. A "no data" response (INFO-200) returns as empty.
88
+ """
89
+ url = _build_url(service, api_key, lang, start_row, end_row, tail)
90
+ last_error: ECOSNetworkError | None = None
91
+ last_cause: Exception | None = None
92
+ for attempt in range(self._max_attempts):
93
+ self._wait_for_next_slot()
94
+ try:
95
+ response = self._client.get(url)
96
+ response.raise_for_status()
97
+ payload = response.json()
98
+ except httpx.HTTPStatusError as err:
99
+ status = err.response.status_code
100
+ if status == 429: # an HTTP-level rate limit, should ECOS send one
101
+ raise ECOSRateLimitError(_RATE_LIMIT_CODE, str(err)) from err
102
+ if status < 500: # any other 4xx is the server's answer
103
+ raise ECOSNetworkError(str(err)) from err
104
+ last_error, last_cause = ECOSNetworkError(str(err)), err # 5xx -- retry
105
+ except httpx.HTTPError as err: # timeout, connection reset, ...
106
+ last_error, last_cause = ECOSNetworkError(str(err)), err
107
+ except json.JSONDecodeError as err:
108
+ # A 200 whose body is not JSON (a proxy/maintenance HTML page) must
109
+ # surface through the ECOSError hierarchy, not as a raw decode error.
110
+ raise ECOSResponseError(
111
+ "UNKNOWN", f"non-JSON response from ECOS: {err}") from err
112
+ else:
113
+ return _extract_body(payload, service)
114
+ if attempt + 1 < self._max_attempts:
115
+ time.sleep(_RETRY_BACKOFF_SECONDS * _RETRY_BACKOFF_FACTOR**attempt)
116
+ if last_error is not None:
117
+ # Chain the httpx cause the ECOSNetworkError docstring promises.
118
+ raise last_error from last_cause
119
+ raise ECOSNetworkError("request failed")
120
+
121
+ def _wait_for_next_slot(self) -> None:
122
+ if self._delay_seconds <= 0:
123
+ return
124
+ now = time.monotonic()
125
+ if now < self._next_request_at:
126
+ time.sleep(self._next_request_at - now)
127
+ self._next_request_at = time.monotonic() + self._delay_seconds
128
+
129
+
130
+ def _build_url(
131
+ service: str,
132
+ api_key: str,
133
+ lang: str,
134
+ start_row: int,
135
+ end_row: int,
136
+ tail: list[str],
137
+ ) -> str:
138
+ segments = [service, api_key, "json", lang, str(start_row), str(end_row), *tail]
139
+ while segments and segments[-1] == "": # trailing optional args ECOS omits
140
+ segments.pop()
141
+ if "" in segments:
142
+ # A remaining empty is an interior positional gap (end without start, or
143
+ # item_code2 without item_code1); it would shift every later argument.
144
+ raise ValueError(
145
+ "cannot build a request with a gap between positional arguments"
146
+ )
147
+ path = "/".join(quote(segment, safe="") for segment in segments)
148
+ return f"{BASE_URL}/{path}"
149
+
150
+
151
+ def _extract_body(payload: Any, service: str) -> dict[str, Any]:
152
+ if not isinstance(payload, dict):
153
+ raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
154
+ if service in payload:
155
+ body = payload[service]
156
+ if not isinstance(body, dict): # a non-object under the service key is bad
157
+ raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
158
+ if "row" not in body and "list_total_count" not in body:
159
+ # A dict under the service key that carries no page fields (e.g. a
160
+ # nested RESULT error) must surface, not read as an empty series.
161
+ raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
162
+ return body
163
+
164
+ result = payload.get("RESULT")
165
+ if not isinstance(result, dict):
166
+ raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
167
+
168
+ code = result.get("CODE", "UNKNOWN")
169
+ message = result.get("MESSAGE", "")
170
+ if code == "INFO-200": # no matching data -- an empty result, not a failure
171
+ return {"list_total_count": "0", "row": []}
172
+ if code == "INFO-100": # invalid authentication key
173
+ raise ECOSAuthError(message or "invalid ECOS API key")
174
+ if code == _RATE_LIMIT_CODE: # too many calls -- rate limited, back off
175
+ raise ECOSRateLimitError(code, message)
176
+ raise ECOSResponseError(code, message)
pyecos/catalog.py ADDED
@@ -0,0 +1,81 @@
1
+ """Offline search over a bundled snapshot of the ECOS table catalog.
2
+
3
+ The hard part of using ECOS is finding the stat code you need -- and
4
+ :meth:`~pyecos.ECOS.fetch_tables` costs a network call every time. This module
5
+ ships a snapshot of the table hierarchy inside the package, so you can search it
6
+ by name or code with **no key and no network**::
7
+
8
+ from pyecos import catalog
9
+
10
+ for table in catalog.search("소비자물가"):
11
+ print(table["stat_code"], table["stat_name"]) # 901Y009 소비자물가지수
12
+
13
+ catalog.table("901Y009") # one table's row, or None
14
+
15
+ The snapshot is a point-in-time copy (refresh it with ``tools/gen_catalog.py``);
16
+ for the live hierarchy, or a table's detail items, use ``ECOS.fetch_tables`` /
17
+ ``ECOS.fetch_items``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import csv
23
+ import gzip
24
+ import io
25
+ from collections.abc import Iterable
26
+ from functools import cache
27
+ from importlib.resources import files
28
+ from typing import cast
29
+
30
+ from .types import CatalogRow
31
+
32
+ _DATA = files("pyecos").joinpath("data", "catalog.tsv.gz")
33
+
34
+
35
+ @cache
36
+ def _tables() -> tuple[CatalogRow, ...]:
37
+ text = gzip.decompress(_DATA.read_bytes()).decode("utf-8")
38
+ return tuple(
39
+ CatalogRow(
40
+ stat_code=row["stat_code"],
41
+ stat_name=row["stat_name"],
42
+ cycle=row["cycle"],
43
+ searchable=row["searchable"] == "Y",
44
+ )
45
+ for row in csv.DictReader(io.StringIO(text), delimiter="\t")
46
+ )
47
+
48
+
49
+ def _copies(rows: Iterable[CatalogRow]) -> list[CatalogRow]:
50
+ # Hand out fresh row dicts so a caller mutating a result can't corrupt the
51
+ # shared snapshot -- the same isolation _Cache applies to fetched rows.
52
+ return [cast("CatalogRow", dict(row)) for row in rows]
53
+
54
+
55
+ def tables() -> list[CatalogRow]:
56
+ """Every table in the bundled catalog snapshot."""
57
+ return _copies(_tables())
58
+
59
+
60
+ def table(stat_code: str) -> CatalogRow | None:
61
+ """The catalog row for ``stat_code``, or ``None`` if the snapshot lacks it."""
62
+ row = next((row for row in _tables() if row["stat_code"] == stat_code), None)
63
+ return cast("CatalogRow", dict(row)) if row is not None else None
64
+
65
+
66
+ def search(query: str, *, searchable_only: bool = True) -> list[CatalogRow]:
67
+ """Tables whose code or name contains ``query``, case-insensitively, offline.
68
+
69
+ ``searchable_only`` (the default) keeps only tables you can actually query
70
+ with :meth:`~pyecos.ECOS.fetch_series`, dropping the category headers that
71
+ organize the hierarchy; pass ``False`` to search those too.
72
+ """
73
+ needle = query.strip().lower()
74
+ hits = [
75
+ row
76
+ for row in _tables()
77
+ if needle in row["stat_code"].lower() or needle in row["stat_name"].lower()
78
+ ]
79
+ if searchable_only:
80
+ hits = [row for row in hits if row["searchable"]]
81
+ return _copies(hits)