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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **The plain-string deprecation introduced in 0.5.0 is withdrawn.** Strings are permanently
supported, the `DeprecationWarning` is gone, and 1.0.0 will not remove them. If you migrated to
the enums while 0.5.0 was current, nothing you wrote breaks: the enums are staying too.

0.5.0 was wrong, and the reason is worth stating rather than quietly reverting. Every comparable
library passes options as plain strings and none export enums: scikit-learn
(`KMeans(init="k-means++")`), numpy (`np.pad(mode="constant")`), scipy
(`linkage(method="single")`), and both SOM libraries, minisom and sompy. Removing the string form
would have made this the only library in its ecosystem to reject `mode="batch"`.

The benefit originally claimed for enums was that a type checker catches typos. That is already
delivered by the `Literal` unions added in 0.4.0, with strings still working: `mode="bacth"` is a
type error while `mode="batch"` is not. The deprecation bought nothing that was not already had.

The decision was made without checking what comparable libraries do. That check is now part of
planning any future API change.

## [0.5.0] - 2026-07-30

One change: the plain-string options now warn. Nothing else moves, and no result changes.
Expand Down
50 changes: 20 additions & 30 deletions docs/reference/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,40 +60,30 @@ when the value comes from outside the program.
The legacy spelling `"mexicanhat"` still resolves and is still accepted, but has no enum member:
one canonical spelling per option is most of the point of having an enum.

## Deprecation timetable for plain strings
## Both spellings are permanent

| Version | What happens |
| --- | --- |
| 0.4.0 | Enums added. Strings accepted, no warning. |
| **0.5.0 (current)** | Strings emit `DeprecationWarning`. |
| 1.0.0 | Strings removed; the enums become the only accepted form. |

0.4.0 deliberately warned about nothing, because `mode="batch"` was what this documentation showed
until that release. The written notice came first, and this is the warning that follows it.

The warning names the exact replacement, so migrating is a substitution you read off the message:

```
DeprecationWarning: Passing mode='batch' as a plain string is deprecated and will
stop working in 1.0.0. Use TrainingMode.BATCH instead.
```
Neither form is deprecated and neither is going away.

Two things it deliberately does not do. It does not fire for a spelling that was never valid, so
`mode="stochastic"` still raises its `ValueError` rather than burying the real mistake under a
notice about modernising something that never worked. And it does not fire when a map is loaded
from an artifact: the name in a `.npz` is a serialisation detail, since JSON has no enums, so the
loader converts it rather than warning you about a string you never wrote.
0.5.0 briefly made plain strings emit `DeprecationWarning`, announcing removal in 1.0.0. **0.6.0
withdrew that.** If you migrated in between, your code still works and nothing is wasted beyond the
time; the enums are staying too.

To silence it while you migrate:
The reason for the reversal is that every comparable library passes options as strings:

```python
import warnings

warnings.filterwarnings("ignore", category=DeprecationWarning, module="python_som")
```

Migrating early costs nothing and is a mechanical substitution: `"batch"` becomes
`TrainingMode.BATCH`, and so on down the table above.
| library | how options are passed |
| --- | --- |
| scikit-learn | `KMeans(init="k-means++", algorithm="lloyd")` |
| numpy | `np.pad(mode="constant")`, `np.linalg.norm(ord="fro")` |
| scipy | `linkage(method="single")`, `minimize(method="BFGS")` |
| minisom | `neighborhood_function="gaussian"`, `topology="rectangular"` |
| sompy | `normalization="var"`, `neighborhood="gaussian"` |

None of them export enums at all. Removing the string form would have made this the only library in
its ecosystem to reject `mode="batch"`, a worse trade than any tidiness it bought.

The argument originally made for enums was that a type checker catches typos. That benefit is real
and already delivered by the `Literal` unions above, without removing anything: `mode="bacth"` is a
type error today while `mode="batch"` is not.

## Learning rate

Expand Down
61 changes: 10 additions & 51 deletions src/python_som/_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
0.5.x series. ``mode=TrainingMode.BATCH`` and ``mode="batch"`` are interchangeable, compare equal,
hash equal, and serialise to the same JSON, because each member *is* a ``str``.

**Deprecation runway.** Plain strings are deprecated as of 0.4.0 and emit ``DeprecationWarning``
from 0.5.0. 0.4.0 deliberately warned about nothing, because ``mode="batch"`` was what every
documentation page showed at the time, so the written notice came first and this is the warning that
follows it. 1.0.0 removes strings entirely, and :func:`warn_if_string` goes with them.
**Both spellings are permanent.** 0.5.0 briefly deprecated plain strings and 0.6.0 withdrew that,
because every comparable library passes options as strings: scikit-learn
(``KMeans(init="k-means++")``), numpy (``np.pad(mode="constant")``), scipy
(``linkage(method="single")``), and both SOM peers, minisom and sompy. None of them export enums at
all. Being the only library in the ecosystem to reject ``mode="batch"`` would cost users more than
the consistency was worth.

The enums remain because they cost nothing and some callers prefer them. The type-checking benefit
that motivated them is delivered by the ``Literal`` unions below rather than by removing anything:
``mode="bacth"`` is a type error while ``mode="batch"`` is not.

**On the base class.** ``enum.StrEnum`` arrived in Python 3.11 and this package supports 3.10, so
:class:`_StrEnum` reproduces it. A bare ``class X(str, Enum)`` is *not* equivalent: its ``str()``
Expand All @@ -18,7 +24,6 @@

from __future__ import annotations

import warnings
from enum import Enum
from typing import Literal

Expand All @@ -31,7 +36,6 @@
"TrainingModeStr",
"WeightInit",
"WeightInitStr",
"warn_if_string",
]


Expand Down Expand Up @@ -110,48 +114,3 @@ class SampleMode(_StrEnum):

#: Accepted strings for the ``sample_mode`` argument of random initialization.
SampleModeStr = Literal["standard_normal", "uniform"]


#: Names that are accepted but have no member of their own, mapped to the member that replaces them.
#: ``mexicanhat`` is the spelling the original contribution used; the enum carries one canonical
#: spelling per option, so this is where the other one is answered for.
_LEGACY_SPELLINGS = {"mexicanhat": "Neighborhood.MEXICAN_HAT"}


def warn_if_string(value: object, enum: type[Enum], parameter: str, *, stacklevel: int = 3) -> None:
"""Emit a ``DeprecationWarning`` when a plain string is passed instead of an enum member.

An enum member *is* a ``str``, so the test is "a string that is not one of ours" rather than
``isinstance(value, str)``, which would fire on the enum too.

Only *valid* spellings warn. A string naming nothing returns silently and is left to whatever
validates it, so ``mode="stochastic"`` still raises its ``ValueError`` rather than surfacing a
deprecation notice about a spelling that never worked.

The message names the exact replacement rather than describing one. A deprecation warning that
makes the reader work out the substitution is a deprecation warning people silence.

:param value: What the caller passed.
:param enum: The enum that should replace a string here.
:param parameter: Name of the parameter, for the message.
:param stacklevel: Frames to skip so the warning blames the caller's line rather than this one.
"""
if isinstance(value, enum) or not isinstance(value, str):
return

try:
replacement = f"{enum.__name__}.{enum(value).name}"
except ValueError:
replacement = _LEGACY_SPELLINGS.get(value, "")
if not replacement:
# Not a value this enum knows and not a legacy alias, so it is simply wrong. Whoever
# validates it will say so; telling the caller to modernise a spelling that was never
# valid would bury the actual error under a deprecation notice.
return

warnings.warn(
f"Passing {parameter}={value!r} as a plain string is deprecated and will stop working in "
f"1.0.0. Use {replacement} instead.",
DeprecationWarning,
stacklevel=stacklevel,
)
6 changes: 0 additions & 6 deletions src/python_som/_som.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
TrainingModeStr,
WeightInit,
WeightInitStr,
warn_if_string,
)
from ._version import __version__

Expand Down Expand Up @@ -272,7 +271,6 @@ def __init__(
self._min_neighborhood_radius = float(min_neighborhood_radius)
self._neighborhood_function_name = neighborhood_function
self._neighborhood_function: NeighborhoodFunction = resolve(neighborhood_function)
warn_if_string(neighborhood_function, Neighborhood, "neighborhood_function")
self._distance_function = distance_function
self._cyclic = (bool(cyclic_x), bool(cyclic_y))

Expand Down Expand Up @@ -442,7 +440,6 @@ def train(
f"Value should be one of {list(TRAINING_MODES)}"
)
raise ValueError(msg)
warn_if_string(mode, TrainingMode, "mode")
if mode == "batch" and self._neighborhood_function_name in SIGNED_NEIGHBORHOODS:
msg = (
f"The {str(self._neighborhood_function_name)!r} neighborhood function cannot be "
Expand Down Expand Up @@ -756,9 +753,6 @@ def weight_initialization(
f"Value should be one of {list(INITIALIZATION_MODES)}"
)
raise ValueError(msg)
warn_if_string(mode, WeightInit, "mode")
if "sample_mode" in kwargs:
warn_if_string(kwargs["sample_mode"], SampleMode, "sample_mode")
try:
if mode == "random":
self._weights = random_models(
Expand Down
Loading