Skip to content

Word API

blacksquare.word.Word

An object representing a single Word, with awareness of the parent grid. Should not be constructed by the user.

Source code in src/blacksquare/word.py
class Word:
    """An object representing a single Word, with awareness of the parent grid. Should
    not be constructed by the user.
    """

    _parent: Crossword | None

    def __init__(
        self,
        parent_crossword: Crossword | None,
        direction: Direction,
        number: int,
        clue: str = "",
    ):
        self._parent = parent_crossword
        self._direction = direction
        self._number = number
        if (
            clue
            and parent_crossword is not None
            and hasattr(parent_crossword, "_inner")
        ):
            rust_dir = parent_crossword._to_rust_dir(direction)
            parent_crossword._inner.set_clue(rust_dir, number, clue)

    def __getitem__(self, key) -> Cell:
        return self.cells[key]

    def __setitem__(self, key, value):
        self.cells[key].value = value

    @property
    def direction(self) -> Direction:
        """The Direction of the word."""
        return self._direction

    @property
    def number(self) -> int:
        """The index number of the word."""
        return self._number

    @property
    def index(self) -> WordIndex:
        """The index (Direction, number) of the word."""
        return (self.direction, self.number)

    @property
    def clue(self) -> str:
        """The clue for the word."""
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            return self._parent._inner.get_clue(rust_dir, self.number)
        return ""

    @clue.setter
    def clue(self, value: str):
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            self._parent._inner.set_clue(rust_dir, self.number, str(value))

    @property
    def value(self) -> str:
        """The current string value of the word."""
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            val = self._parent._inner.get_word_value(rust_dir, self.number)
            if val is not None:
                return val
        return "".join([c.str for c in self.cells])

    @value.setter
    def value(self, value: str):
        if self._parent is not None:
            self._parent[self.index] = value
        else:
            for cell, v in zip(self.cells, value):
                cell.value = v

    @property
    def cells(self) -> list[Cell]:
        """A list of the cells that make up the word."""
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            cell_indices = self._parent._inner.get_word_cell_indices(
                rust_dir, self.number
            )
            if cell_indices is not None:
                return [self._parent[r, c] for r, c in cell_indices]
        return []

    def is_open(self) -> bool:
        """Returns True if the word contains open cells, False otherwise."""
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            return self._parent._inner.is_word_open(rust_dir, self.number)
        return any(c.is_open() for c in self.cells)

    @property
    def crosses(self) -> list[Word | None]:
        """Returns the words that cross the current word.

        Returns:
            A list of Word objects corresponding to the crosses.
        """
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            cross_tuples = self._parent._inner.get_word_crosses(rust_dir, self.number)
            if cross_tuples is not None:
                res: list[Word | None] = []
                for ct in cross_tuples:
                    if ct is not None:
                        py_dir = self._parent._from_rust_dir(ct[0])
                        res.append(self._parent[py_dir, ct[1]])
                    else:
                        res.append(None)
                return res
        return [cell.get_parent_word(self.direction.opposite) for cell in self.cells]

    @property
    def symmetric_image(self) -> Word | list[Word] | None:
        if self._parent is not None:
            res = self._parent.get_symmetric_word_index(self.index)
            if not res:
                return None
            elif isinstance(res, list):
                return [self._parent[i] for i in res]
            else:
                return self._parent[res]
        return None

    def find_matches(
        self, word_list: WordList | None = None, allow_repeats: bool = False
    ) -> MatchWordList:
        """Finds matches for the word, ranking matches by how many valid crosses they
        allow.

        Args:
            word_list: The word list to use for matching. If None, the default wordlist
                of the parent crossword is used.
            allow_repeats: Whether to include words that are already in the grid.
                Defaults to False.

        Returns:
            The matching words, scored by compatible crosses.
        """
        if self._parent is not None:
            effective_wl = self._parent.word_list if word_list is None else word_list
        else:
            effective_wl = word_list
        if effective_wl is None:
            raise ValueError("No word list provided")

        self_len = len(self)
        open_indices = [i for i, c in enumerate(self.cells) if c.is_open()]

        if hasattr(effective_wl, "_inner"):
            open_pos_list = []
            letter_weights_list = []
            for idx in open_indices:
                cross = self.crosses[idx]
                if cross is None:
                    continue
                cross_index = cross.crosses.index(self)
                cross_matches = effective_wl.find_matches(cross)
                scores_dict = cross_matches.letter_scores_at_index(cross_index)
                weights = [
                    scores_dict.get(chr(ord("A") + i), 0.0)
                    * INVERSE_CHARACTER_FREQUENCIES.get(chr(ord("A") + i), 1.0)
                    for i in range(26)
                ]
                open_pos_list.append(int(idx))
                letter_weights_list.append(weights)

            inner_res = effective_wl._inner.fused_cross_matches(
                self.value, open_pos_list, letter_weights_list
            )
            matches = MatchWordList(
                inner_res.word_length,
                inner=inner_res,
            )
            if not allow_repeats and self._parent is not None:
                matches = matches.filter_words(
                    [
                        w.value
                        for w in self._parent.iterwords()
                        if len(w) == self_len and not w.is_open()
                    ]
                )
            return matches

        matches = effective_wl.find_matches(self)
        if not allow_repeats and self._parent is not None:
            matches = matches.filter_words(
                [
                    w.value
                    for w in self._parent.iterwords()
                    if len(w) == self_len and not w.is_open()
                ]
            )
        letter_scores_per_index = {}
        for idx in open_indices:
            cross = self.crosses[idx]
            if cross is None:
                continue
            cross_index = cross.crosses.index(self)
            cross_matches = effective_wl.find_matches(cross)
            letter_scores_per_index[idx] = cross_matches.letter_scores_at_index(
                cross_index
            )

        def score_word_fn(word: str, score: float) -> float:
            per_letter_scores = [
                letter_scores_per_index[i].get(word[i], 0)
                * INVERSE_CHARACTER_FREQUENCIES.get(word[i], 1)
                for i in open_indices
                if i in letter_scores_per_index
            ]
            return math.log(math.prod(per_letter_scores) + 1.0) * score

        return matches.rescore(score_word_fn)

    def __repr__(self):
        return f'Word({self.direction.value} {self.number}: "{self.value.replace(" ", "?")}")'

    def __len__(self):
        if self._parent is not None and hasattr(self._parent, "_inner"):
            rust_dir = self._parent._to_rust_dir(self.direction)
            length = self._parent._inner.get_word_length(rust_dir, self.number)
            if length is not None:
                return length
        return len(self.cells)

    def __deepcopy__(self, memo):
        copied = copy.copy(self)
        copied._parent = None
        return copied

cells property

cells: list[Cell]

A list of the cells that make up the word.

clue property writable

clue: str

The clue for the word.

crosses property

crosses: list[Word | None]

Returns the words that cross the current word.

Returns:

Type Description
list[Word | None]

A list of Word objects corresponding to the crosses.

direction property

direction: Direction

The Direction of the word.

index property

index: WordIndex

The index (Direction, number) of the word.

number property

number: int

The index number of the word.

value property writable

value: str

The current string value of the word.

find_matches

find_matches(
    word_list: WordList | None = None,
    allow_repeats: bool = False,
) -> MatchWordList

Finds matches for the word, ranking matches by how many valid crosses they allow.

Parameters:

Name Type Description Default
word_list WordList | None

The word list to use for matching. If None, the default wordlist of the parent crossword is used.

None
allow_repeats bool

Whether to include words that are already in the grid. Defaults to False.

False

Returns:

Type Description
MatchWordList

The matching words, scored by compatible crosses.

Source code in src/blacksquare/word.py
def find_matches(
    self, word_list: WordList | None = None, allow_repeats: bool = False
) -> MatchWordList:
    """Finds matches for the word, ranking matches by how many valid crosses they
    allow.

    Args:
        word_list: The word list to use for matching. If None, the default wordlist
            of the parent crossword is used.
        allow_repeats: Whether to include words that are already in the grid.
            Defaults to False.

    Returns:
        The matching words, scored by compatible crosses.
    """
    if self._parent is not None:
        effective_wl = self._parent.word_list if word_list is None else word_list
    else:
        effective_wl = word_list
    if effective_wl is None:
        raise ValueError("No word list provided")

    self_len = len(self)
    open_indices = [i for i, c in enumerate(self.cells) if c.is_open()]

    if hasattr(effective_wl, "_inner"):
        open_pos_list = []
        letter_weights_list = []
        for idx in open_indices:
            cross = self.crosses[idx]
            if cross is None:
                continue
            cross_index = cross.crosses.index(self)
            cross_matches = effective_wl.find_matches(cross)
            scores_dict = cross_matches.letter_scores_at_index(cross_index)
            weights = [
                scores_dict.get(chr(ord("A") + i), 0.0)
                * INVERSE_CHARACTER_FREQUENCIES.get(chr(ord("A") + i), 1.0)
                for i in range(26)
            ]
            open_pos_list.append(int(idx))
            letter_weights_list.append(weights)

        inner_res = effective_wl._inner.fused_cross_matches(
            self.value, open_pos_list, letter_weights_list
        )
        matches = MatchWordList(
            inner_res.word_length,
            inner=inner_res,
        )
        if not allow_repeats and self._parent is not None:
            matches = matches.filter_words(
                [
                    w.value
                    for w in self._parent.iterwords()
                    if len(w) == self_len and not w.is_open()
                ]
            )
        return matches

    matches = effective_wl.find_matches(self)
    if not allow_repeats and self._parent is not None:
        matches = matches.filter_words(
            [
                w.value
                for w in self._parent.iterwords()
                if len(w) == self_len and not w.is_open()
            ]
        )
    letter_scores_per_index = {}
    for idx in open_indices:
        cross = self.crosses[idx]
        if cross is None:
            continue
        cross_index = cross.crosses.index(self)
        cross_matches = effective_wl.find_matches(cross)
        letter_scores_per_index[idx] = cross_matches.letter_scores_at_index(
            cross_index
        )

    def score_word_fn(word: str, score: float) -> float:
        per_letter_scores = [
            letter_scores_per_index[i].get(word[i], 0)
            * INVERSE_CHARACTER_FREQUENCIES.get(word[i], 1)
            for i in open_indices
            if i in letter_scores_per_index
        ]
        return math.log(math.prod(per_letter_scores) + 1.0) * score

    return matches.rescore(score_word_fn)

is_open

is_open() -> bool

Returns True if the word contains open cells, False otherwise.

Source code in src/blacksquare/word.py
def is_open(self) -> bool:
    """Returns True if the word contains open cells, False otherwise."""
    if self._parent is not None and hasattr(self._parent, "_inner"):
        rust_dir = self._parent._to_rust_dir(self.direction)
        return self._parent._inner.is_word_open(rust_dir, self.number)
    return any(c.is_open() for c in self.cells)