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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes are listed here. Format based on [Keep a Changelog](https://keepachangelog.com/).

## Unreleased

### Added
- `whole_word` option for `Michraz.search` and `--whole-word` for the CLI: match the keyword as a whole word, so `נוי`
does not match `בינוי` or `פינוי`. Hebrew prefix letters (`ו ה כ ל מ ב ש`) are allowed in front of the word.

## 0.2.0 (2026-11-05)

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ to_csv(tenders, "tenders.csv")
```

Results are sorted by deadline. Filter by `source`, `closing_before` and `closing_after` (dates or aware
datetimes) too. Each tender has an `attribution` string. Keep it when you republish the data.
datetimes) too. Keyword search matches text inside words. Pass `whole_word=True` (or `--whole-word`) to match whole words only, so
`נוי` finds `גינון ונוי` but not `בינוי`. Hebrew prefix letters like `ה` and `ל` are allowed in front of the word.
Each tender has an `attribution` string. Keep it when you republish the data.

Some city sites list staff vacancies next to their tenders. They are hidden by default. Pass `kind=None` to see both,
or `--kind all` on the command line.
Expand Down
Binary file added docs/images/pr-whole-word.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion src/michraz/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def _build_parser() -> argparse.ArgumentParser:

search = commands.add_parser(Command.SEARCH.value, parents=[common], help="search tenders")
search.add_argument("--keyword", "-k", help="text to look for in the title or number")
search.add_argument(
"--whole-word", action="store_true", help="match the keyword as a whole word, not as part of one"
)
search.add_argument("--open", action="store_true", help="only tenders that can still receive submissions")
search.add_argument("--source", choices=[source.name for source in BUILT_IN], help="search a single source")
search.add_argument(
Expand Down Expand Up @@ -117,7 +120,9 @@ def line(cells: Sequence[str], title: str) -> str:

def _search(args: argparse.Namespace, m: Michraz) -> int:
kind = None if args.kind == ALL_KINDS else TenderKind(args.kind)
tenders = m.search(keyword=args.keyword, open_only=args.open, source=args.source, kind=kind)
tenders = m.search(
keyword=args.keyword, whole_word=args.whole_word, open_only=args.open, source=args.source, kind=kind
)
if args.csv:
count = to_csv(tenders, args.csv)
print(f"wrote {count} tenders to {args.csv}", file=sys.stderr)
Expand Down
4 changes: 4 additions & 0 deletions src/michraz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def search(
self,
keyword: str | None = None,
open_only: bool = False,
whole_word: bool = False,
source: str | None = None,
closing_before: datetime | date | None = None,
closing_after: datetime | date | None = None,
Expand All @@ -75,6 +76,8 @@ def search(

Args:
keyword: Keep tenders whose title or number contains this text (case-insensitive, NFKC).
whole_word: Match ``keyword`` as a whole word, so ``נוי`` does not match ``בינוי``. Hebrew prefix letters
such as ``ה`` and ``ל`` are allowed in front of the word.
open_only: Keep only tenders that can still receive submissions.
source: Search only the source with this name.
closing_before: Keep tenders whose deadline is before this moment. A date means the start of
Expand All @@ -94,6 +97,7 @@ def search(
"""
criteria = TenderFilter(
keyword=keyword,
whole_word=whole_word,
open_only=open_only,
closing_before=closing_before,
closing_after=closing_after,
Expand Down
25 changes: 21 additions & 4 deletions src/michraz/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
import unicodedata
from collections.abc import Iterable
from dataclasses import dataclass
Expand All @@ -12,6 +13,9 @@
from michraz.models import Tender, TenderKind

NORMALIZATION_FORM: Final = "NFKC"
HEBREW_PREFIXES: Final = "והכלמבש"
"""One-letter words that attach to the front of a Hebrew word: and, the, as, to, from, in, that."""
MAX_PREFIX_LETTERS: Final = 3

_EARLIEST = datetime.min.replace(tzinfo=_dates.JERUSALEM)

Expand All @@ -21,14 +25,23 @@ def normalize(text: str) -> str:
return unicodedata.normalize(NORMALIZATION_FORM, text).casefold()


def matches_keyword(tender: Tender, keyword: str) -> bool:
def matches_keyword(tender: Tender, keyword: str, whole_word: bool = False) -> bool:
"""Tells whether ``keyword`` occurs in the title or the number of ``tender``.

The match is a case-insensitive substring match on NFKC-normalized text. There is no stemming.
The match is case-insensitive on NFKC-normalized text. There is no stemming.

Args:
tender: The tender to look at.
keyword: Text to look for.
whole_word: Match whole words only. A Hebrew word may carry up to three prefix letters (``ו ה כ ל מ ב ש``),
so ``פרחים`` also matches ``הפרחים`` and ``לפרחים`` but ``נוי`` does not match ``בינוי`` or ``פינוי``.
"""
needle = normalize(keyword.strip())
haystack = normalize(f"{tender.title}\n{tender.number or ''}")
return needle in haystack
if not whole_word:
return needle in haystack
prefix = f"[{HEBREW_PREFIXES}]{{0,{MAX_PREFIX_LETTERS}}}"
return re.search(rf"(?<!\w){prefix}{re.escape(needle)}(?!\w)", haystack) is not None


def _bound(value: datetime | date | None) -> datetime | None:
Expand All @@ -47,6 +60,7 @@ class TenderFilter:

Attributes:
keyword: Keep tenders whose title or number contains this text.
whole_word: Match ``keyword`` as a whole word. See :func:`matches_keyword`.
open_only: Keep tenders for which :meth:`Tender.is_open` is true.
closing_before: Keep tenders whose deadline is strictly before this moment. A plain date means
00:00 at the start of that day in Asia/Jerusalem.
Expand All @@ -59,6 +73,7 @@ class TenderFilter:
"""

keyword: str | None = None
whole_word: bool = False
open_only: bool = False
closing_before: datetime | date | None = None
closing_after: datetime | date | None = None
Expand All @@ -75,7 +90,7 @@ def matches(self, tender: Tender) -> bool:
"""Tells whether ``tender`` meets every condition."""
if self.kind is not None and tender.kind != self.kind:
return False
if self.keyword and not matches_keyword(tender, self.keyword):
if self.keyword and not matches_keyword(tender, self.keyword, self.whole_word):
return False
if self.open_only and not tender.is_open(self.now):
return False
Expand All @@ -99,6 +114,7 @@ def apply_filters(
tenders: Iterable[Tender],
*,
keyword: str | None = None,
whole_word: bool = False,
open_only: bool = False,
closing_before: datetime | date | None = None,
closing_after: datetime | date | None = None,
Expand All @@ -108,6 +124,7 @@ def apply_filters(
"""Filters tenders. Shorthand for ``TenderFilter(...).apply(tenders)``; see :class:`TenderFilter`."""
return TenderFilter(
keyword=keyword,
whole_word=whole_word,
open_only=open_only,
closing_before=closing_before,
closing_after=closing_after,
Expand Down
6 changes: 6 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,9 @@ def test_a_command_is_required(capsys):
with pytest.raises(SystemExit) as exit_info:
main([])
assert exit_info.value.code == 2


def test_whole_word_option(served, capsys):
base = ("search", "--no-cache", "--source", "beersheva", "--keyword", "גינו")
assert "1 tender\n" in run(capsys, *base)[1] # part of "גינון"
assert "0 tenders\n" in run(capsys, *base, "--whole-word")[1]
26 changes: 26 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,29 @@ def test_kind_filter_keeps_only_that_kind():
assert apply_filters([tender, vacancy], kind=TenderKind.TENDER) == [tender]
assert apply_filters([tender, vacancy], kind=TenderKind.VACANCY) == [vacancy]
assert apply_filters([tender, vacancy]) == [tender, vacancy]


@pytest.mark.parametrize(
("title", "keyword", "expected"),
[
("מכרז לפרחים", "פרחים", True), # to the flowers
("רובע הפרחים", "פרחים", True), # the flowers
("הפרחים והצמחים", "צמחים", True), # and-the plants
("בינוי ופיתוח", "נוי", False),
("פינוי פסולת", "נוי", False),
("נוירו", "נוי", False),
("מאגר היועצים", "עצים", False),
("גיזום עצים", "עצים", True),
("Gardening services", "garden", False),
("Garden services", "GARDEN", True),
("שירותי גינון ונוי", "נוי", True),
("מכרז 12/2026", "12/2026", True),
],
)
def test_whole_word_keyword(title, keyword, expected):
assert matches_keyword(make_tender(title=title), keyword, whole_word=True) is expected


def test_substring_match_is_still_the_default():
assert matches_keyword(make_tender(title="בינוי ופיתוח"), "נוי")
assert apply_filters([make_tender(title="בינוי")], keyword="נוי", whole_word=True) == []
Loading