pdacek 0.1.0__tar.gz

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.
@@ -0,0 +1,26 @@
1
+ # build artefacts
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .eggs/
6
+
7
+ # environments
8
+ .venv/
9
+ venv/
10
+ __pycache__/
11
+ *.py[cod]
12
+
13
+ # tooling
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # secrets — never commit a PyPI token
21
+ .pypirc
22
+ .pypi-token
23
+
24
+ # OS noise
25
+ .DS_Store
26
+ Thumbs.db
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
+ and the project uses [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-11-15
8
+
9
+ ### Added
10
+
11
+ - Five experiments: preprocessing pipeline, n-gram probabilities, minimum edit
12
+ distance, CFG parsing (top-down and bottom-up), add-one Naive Bayes.
13
+ - `pdacek.p1()` … `pdacek.p5()` shorthands for notebook use.
14
+ - `source(n)` / `save(n)` for standalone listings.
15
+ - `pdacek` console script and `python -m pdacek` entry point.
16
+ - Regex tokenizer and built-in stop list as offline fallbacks for missing NLTK
17
+ corpora.
18
+ - Test suite covering every experiment, the public API and the CLI.
pdacek-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sohel Havaldar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pdacek-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: pdacek
3
+ Version: 0.1.0
4
+ License: MIT
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: nltk>=3.8
8
+ Provides-Extra: dev
9
+ Requires-Dist: build>=1.2; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: twine>=5.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ import pdacek
15
+ pdacek.p1() # preprocessing pipeline
16
+ pdacek.p2() # n-gram probabilities
17
+ pdacek.p3() # minimum edit distance
18
+ pdacek.p4() # top-down / bottom-up parsing
19
+ pdacek.p5() # Naive Bayes
@@ -0,0 +1,103 @@
1
+ # Publishing checklist
2
+
3
+ Everything below is local until the upload step — nothing is sent anywhere
4
+ without a PyPI token.
5
+
6
+ ## 1. The name
7
+
8
+ `pdacek` — checked free on PyPI on 2026-11-15 (a 404 from the JSON API). Kept short
9
+ on purpose: `import pdacek`, `pdacek.p1()`, `pdacek 3`. It is a valid identifier, not a
10
+ Python keyword, and does not shadow a standard-library module.
11
+
12
+ 83 other two-letter names are also free; the full scan of all 676 combinations
13
+ is in `../two-letter-names/pypi-two-letter-names.csv`. If you ever want to
14
+ switch, one command rewrites `pyproject.toml`, the source directory, imports,
15
+ docs and tests:
16
+
17
+ ```bash
18
+ python tools/rename.py <new-name>
19
+ ```
20
+
21
+ Then rebuild.
22
+
23
+ ## 2. Check the name is still free
24
+
25
+ ```bash
26
+ curl -s -o /dev/null -w "%{http_code}\n" https://pypi.org/pypi/<name>/json
27
+ # 404 = free, 200 = taken
28
+ ```
29
+
30
+ PyPI also rejects names that only differ from an existing project by `-`, `_`
31
+ or `.`, so pick something that normalises differently from `plnlab`.
32
+
33
+ ## 3. Build
34
+
35
+ ```bash
36
+ python -m pip install -U build twine
37
+ python -m build
38
+ python -m twine check dist/*
39
+ ```
40
+
41
+ `dist/` should contain one `.whl` and one `.tar.gz`, both `PASSED`.
42
+
43
+ ## 4. Rehearse on TestPyPI (recommended)
44
+
45
+ ```bash
46
+ python -m twine upload --repository testpypi dist/*
47
+ python -m pip install --index-url https://test.pypi.org/simple/ \
48
+ --extra-index-url https://pypi.org/simple/ pdacek
49
+ ```
50
+
51
+ A TestPyPI rehearsal catches metadata and filename problems without burning a
52
+ version number on the real index.
53
+
54
+ ## 5. Publish for real
55
+
56
+ Create an API token at <https://pypi.org/manage/account/token/> (scope it to the
57
+ project after the first upload), then:
58
+
59
+ ```bash
60
+ export TWINE_USERNAME=__token__
61
+ export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmc... # paste the token
62
+ python -m twine upload dist/*
63
+ ```
64
+
65
+ Or pass it inline without leaving it in shell history:
66
+
67
+ ```bash
68
+ TWINE_USERNAME=__token__ TWINE_PASSWORD="$(cat ~/.pypi-token)" python -m twine upload dist/*
69
+ ```
70
+
71
+ Never commit the token; `~/.pypirc` with `chmod 600` is the usual alternative.
72
+
73
+ ## 6. After the upload
74
+
75
+ ```bash
76
+ pip install pdacek # or the name you chose
77
+ python -c "import pdacek; pdacek.p1()"
78
+ ```
79
+
80
+ The project page appears at `https://pypi.org/project/<name>/` within a minute
81
+ or two; the JSON API updates immediately.
82
+
83
+ ## 7. Releasing again
84
+
85
+ 1. Bump `version` in `pyproject.toml` and `__version__` in
86
+ `src/<name>/__init__.py` (keep them in sync — the test suite checks it).
87
+ 2. Add a `CHANGELOG.md` entry.
88
+ 3. `python -m build && python -m twine upload dist/*`.
89
+
90
+ Versions are immutable on PyPI: a published version can be yanked, not
91
+ replaced, so bump before every upload.
92
+
93
+ ## Notes on what this package does at runtime
94
+
95
+ - No network calls, no telemetry, no file writes outside the paths you pass to
96
+ `save()`.
97
+ - `nltk.download(...)` is only attempted when the local corpus is missing, and
98
+ a failure silently falls back to the built-in regex tokenizer.
99
+ - `save(n)` writes `<slug>.py` in the directory you name (default: current).
100
+
101
+ Kept deliberately boring so the package stays a normal, inspectable library:
102
+ anyone running `pip download` and reading `src/` sees exactly the five lab
103
+ programs and nothing else.
pdacek-0.1.0/README.md ADDED
@@ -0,0 +1,6 @@
1
+ import pdacek
2
+ pdacek.p1() # preprocessing pipeline
3
+ pdacek.p2() # n-gram probabilities
4
+ pdacek.p3() # minimum edit distance
5
+ pdacek.p4() # top-down / bottom-up parsing
6
+ pdacek.p5() # Naive Bayes
@@ -0,0 +1,8 @@
1
+ """Make ``pytest`` work from a fresh clone without installing the package."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SRC = Path(__file__).resolve().parent / "src"
7
+ if SRC.is_dir() and str(SRC) not in sys.path:
8
+ sys.path.insert(0, str(SRC))
@@ -0,0 +1,44 @@
1
+ """What a Colab/Jupyter session looks like, cell by cell.
2
+
3
+ Cell 1
4
+ ------
5
+ %pip install -q pdacek
6
+
7
+ Cell 2
8
+ ------
9
+ import pdacek
10
+ pdacek.programs()
11
+
12
+ Cell 3 - run one experiment inline
13
+ ----------------------------------
14
+ pdacek.p3()
15
+
16
+ Cell 4 - or print the listing and keep editing it below
17
+ --------------------------------------------------------
18
+ pdacek.source(3)
19
+
20
+ Cell 5 - use the functions directly, no printing
21
+ ------------------------------------------------
22
+ from pdacek import NaiveBayes
23
+
24
+ model = NaiveBayes().fit([
25
+ ("fun, couple, love, love", "comedy"),
26
+ ("fast, furious, shoot", "action"),
27
+ ])
28
+ model.predict(["love", "fun"])
29
+ """
30
+
31
+ from pdacek import NaiveBayes, Parser, min_edit_distance, preprocess
32
+
33
+ # Functions instead of printed output: handy when you want to grade results.
34
+ print(min_edit_distance("kitten", "sitting"))
35
+
36
+ parser = Parser([("S", ["NP", "VP"]), ("NP", ["Det", "N"]), ("VP", ["V"])])
37
+ print(parser.top_down_parse(["the", "cat", "chases"]))
38
+
39
+ print(preprocess()["stemmed"][:5])
40
+
41
+ model = NaiveBayes().fit(
42
+ [("fun, couple, love, love", "comedy"), ("fast, furious, shoot", "action")]
43
+ )
44
+ print(model.predict(["love", "fun"]))
@@ -0,0 +1,15 @@
1
+ """Run every experiment in order: python examples/run_all.py [number ...]."""
2
+
3
+ import sys
4
+
5
+ import pdacek
6
+
7
+
8
+ def main(numbers):
9
+ for number in numbers or pdacek.programs():
10
+ print(f"\n{'=' * 70}\nProgram {number}: {pdacek.programs()[number]}\n{'=' * 70}")
11
+ pdacek.run(number)
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main([int(arg) for arg in sys.argv[1:]])
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pdacek"
7
+ version = "0.1.0"
8
+ readme = "README.md"
9
+ requires-python = ">=3.9"
10
+ license = { text = "MIT" }
11
+ dependencies = ["nltk>=3.8"]
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8.0", "build>=1.2", "twine>=5.0"]
15
+
16
+ [project.scripts]
17
+ pdacek = "pdacek.cli:main"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/pdacek"]
21
+
22
+ [tool.hatch.build.targets.sdist]
23
+ include = [
24
+ "src/pdacek",
25
+ "tests",
26
+ "examples",
27
+ "tools",
28
+ "README.md",
29
+ "CHANGELOG.md",
30
+ "PUBLISH.md",
31
+ "LICENSE",
32
+ "conftest.py",
33
+ ]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ addopts = "-q"
@@ -0,0 +1,116 @@
1
+ """pdacek - a plain, notebook-friendly NLP lab toolkit.
2
+
3
+ Five self-contained experiments, each available three ways:
4
+
5
+ * run it inline -> ``pdacek.p3()``
6
+ * read the code -> ``print(pdacek.source(3))``
7
+ * write it to disk -> ``pdacek.save(3)``
8
+
9
+ The functions are also importable individually, e.g.
10
+ ``from pdacek import min_edit_distance``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional
17
+
18
+ from .programs import PROGRAMS, numbers, run, source, titles
19
+ from .programs.pl01_preprocessing import (
20
+ filtration,
21
+ preprocess,
22
+ stopword_removal,
23
+ stemming,
24
+ tokenization,
25
+ validation,
26
+ )
27
+ from .programs.pl02_ngrams import build_corpus, ngram_counts, ngram_probabilities
28
+ from .programs.pl03_edit_distance import edit_table, med, min_edit_distance, trace_operations
29
+ from .programs.pl04_parsing import Parser
30
+ from .programs.pl05_naive_bayes import NaiveBayes
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "__version__",
36
+ "programs",
37
+ "run",
38
+ "source",
39
+ "save",
40
+ "p1",
41
+ "p2",
42
+ "p3",
43
+ "p4",
44
+ "p5",
45
+ # helpers
46
+ "tokenization",
47
+ "filtration",
48
+ "validation",
49
+ "stopword_removal",
50
+ "stemming",
51
+ "preprocess",
52
+ "build_corpus",
53
+ "ngram_counts",
54
+ "ngram_probabilities",
55
+ "edit_table",
56
+ "med",
57
+ "min_edit_distance",
58
+ "trace_operations",
59
+ "Parser",
60
+ "NaiveBayes",
61
+ ]
62
+
63
+
64
+ def programs() -> Dict[int, str]:
65
+ """Return ``{number: title}`` for every bundled experiment."""
66
+ return titles()
67
+
68
+
69
+ def save(number: int, directory: "str | Path" = ".") -> Path:
70
+ """Write a program listing to ``directory`` and return its path."""
71
+ program = PROGRAMS[int(number)]
72
+ target = Path(directory) / program.script_name
73
+ target.parent.mkdir(parents=True, exist_ok=True)
74
+ target.write_text(source(number), encoding="utf-8")
75
+ return target
76
+
77
+
78
+ def _run_all() -> List[int]:
79
+ for number in numbers():
80
+ run(number)
81
+ print()
82
+ return numbers()
83
+
84
+
85
+ def p1() -> None:
86
+ """Program 1 - text preprocessing pipeline."""
87
+ run(1)
88
+
89
+
90
+ def p2() -> None:
91
+ """Program 2 - unigram, bigram, trigram probabilities."""
92
+ run(2)
93
+
94
+
95
+ def p3() -> None:
96
+ """Program 3 - minimum edit distance."""
97
+ run(3)
98
+
99
+
100
+ def p4() -> None:
101
+ """Program 4 - top-down and bottom-up parsing."""
102
+ run(4)
103
+
104
+
105
+ def p5() -> None:
106
+ """Program 5 - add-one smoothed Naive Bayes."""
107
+ run(5)
108
+
109
+
110
+ def __getattr__(name: str) -> Optional[object]: # pragma: no cover - tiny shim
111
+ """Allow ``pdacek.p6`` style access to fail with a clear message."""
112
+ if name.startswith("p") and name[1:].isdigit():
113
+ raise AttributeError(
114
+ f"no program {name[1:]} in this release; available: {numbers()}"
115
+ )
116
+ raise AttributeError(f"module 'pdacek' has no attribute {name!r}")
@@ -0,0 +1,6 @@
1
+ """Support ``python -m pdacek``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,98 @@
1
+ """Command line entry point: ``pdacek 3``, ``pdacek --list``, ..."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import List, Optional
8
+
9
+ from . import __version__, numbers, programs, run, save, source
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="pdacek",
15
+ description="Run the bundled NLP lab experiments without opening an editor.",
16
+ formatter_class=argparse.RawDescriptionHelpFormatter,
17
+ epilog=(
18
+ "examples:\n"
19
+ " pdacek run every experiment\n"
20
+ " pdacek 3 run experiment 3\n"
21
+ " pdacek p3 same thing\n"
22
+ " pdacek 3 --source print the listing instead of running it\n"
23
+ " pdacek 3 --save write the listing to the current directory\n"
24
+ ),
25
+ )
26
+ parser.add_argument(
27
+ "program",
28
+ nargs="?",
29
+ help="experiment number (1-5) or name such as p3",
30
+ )
31
+ parser.add_argument("--list", "-l", action="store_true", help="list experiments and exit")
32
+ parser.add_argument("--source", "-s", action="store_true", help="print the listing")
33
+ parser.add_argument(
34
+ "--save",
35
+ nargs="?",
36
+ const=".",
37
+ metavar="DIR",
38
+ help="write the listing to DIR (default: current directory)",
39
+ )
40
+ parser.add_argument("--version", "-V", action="version", version=f"pdacek {__version__}")
41
+ return parser
42
+
43
+
44
+ def _parse_number(raw: Optional[str]) -> Optional[int]:
45
+ if raw is None:
46
+ return None
47
+ cleaned = raw.strip().lower().lstrip("p").replace("program", "").strip(" _-")
48
+ try:
49
+ return int(cleaned)
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def list_programs() -> None:
55
+ print(f"pdacek {__version__} - bundled experiments\n")
56
+ for number, title in programs().items():
57
+ print(f" {number}. {title}")
58
+ print("\nRun one with: pdacek <number>")
59
+
60
+
61
+ def main(argv: Optional[List[str]] = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+
64
+ if args.list:
65
+ list_programs()
66
+ return 0
67
+
68
+ number = _parse_number(args.program)
69
+ if args.program is not None and number is None:
70
+ print(f"error: could not read a program number from {args.program!r}", file=sys.stderr)
71
+ print(f"available: {', '.join(str(n) for n in numbers())}", file=sys.stderr)
72
+ return 2
73
+
74
+ try:
75
+ if number is None:
76
+ for n in numbers():
77
+ run(n)
78
+ print()
79
+ return 0
80
+
81
+ if args.save is not None:
82
+ path = save(number, args.save)
83
+ print(f"wrote {path}")
84
+ return 0
85
+
86
+ if args.source:
87
+ print(source(number))
88
+ return 0
89
+
90
+ run(number)
91
+ return 0
92
+ except KeyError as exc:
93
+ print(f"error: {exc.args[0]}", file=sys.stderr)
94
+ return 2
95
+
96
+
97
+ if __name__ == "__main__": # pragma: no cover
98
+ raise SystemExit(main())
@@ -0,0 +1,129 @@
1
+ """Shared helpers: tokenisation and NLTK corpus access with safe fallbacks.
2
+
3
+ The lab machines this toolkit targets are often offline, so every helper here
4
+ degrades to a pure-``re`` implementation instead of raising when an NLTK
5
+ resource (``punkt``, ``stopwords``) has not been downloaded yet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import List
12
+
13
+ try: # NLTK is a declared dependency, but keep the package importable without it.
14
+ import nltk
15
+ except ImportError: # pragma: no cover - defensive
16
+ nltk = None # type: ignore[assignment]
17
+
18
+ __all__ = ["tokenize", "stopwords_en", "stemmer", "ensure_punkt", "nltk_available"]
19
+
20
+ _TOKEN_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+|[^\sA-Za-z\d]")
21
+
22
+ # Small built-in list, used only when the NLTK corpus is unavailable.
23
+ _FALLBACK_STOPWORDS = frozenset(
24
+ """
25
+ a about above after again against all am an and any are as at be because been
26
+ before being below between both but by can did do does doing down during each
27
+ few for from further had has have having he her here hers herself him himself
28
+ his how i if in into is it its itself just me more most my myself no nor not
29
+ now of off on once only or other our ours ourselves out over own same she
30
+ should so some such than that the their theirs them themselves then there
31
+ these they this those through to too under until up very was we were what
32
+ when where which while who whom why will with you your yours yourself
33
+ yourselves
34
+ """.split()
35
+ )
36
+
37
+
38
+ def nltk_available() -> bool:
39
+ """Return ``True`` when NLTK can be imported."""
40
+ return nltk is not None
41
+
42
+
43
+ def _resource_present(resource: str, package: str) -> bool:
44
+ if nltk is None:
45
+ return False
46
+ try:
47
+ nltk.data.find(f"{package}/{resource}")
48
+ return True
49
+ except LookupError:
50
+ return False
51
+
52
+
53
+ def ensure_punkt() -> bool:
54
+ """Make sure a sentence/word tokenizer model exists; return availability.
55
+
56
+ NLTK 3.9 renamed the tokenizer data to ``punkt_tab``; both names are tried
57
+ and any download failure (offline machine) is swallowed on purpose.
58
+ """
59
+ if nltk is None:
60
+ return False
61
+ for resource in ("punkt", "punkt_tab"):
62
+ if _resource_present(resource, "tokenizers"):
63
+ return True
64
+ try:
65
+ nltk.download(resource, quiet=True)
66
+ except Exception: # pragma: no cover - network/permission problems
67
+ pass
68
+ if _resource_present(resource, "tokenizers"):
69
+ return True
70
+ return False
71
+
72
+
73
+ def tokenize(text: str) -> List[str]:
74
+ """Split ``text`` into word and punctuation tokens.
75
+
76
+ Uses NLTK's ``word_tokenize`` when the model is available, otherwise a
77
+ regex tokenizer that keeps punctuation as its own token, which is what the
78
+ filtration and script-validation steps in Program 1 need.
79
+ """
80
+ if ensure_punkt():
81
+ try:
82
+ return nltk.word_tokenize(text) # type: ignore[union-attr]
83
+ except LookupError: # pragma: no cover - stale resource index
84
+ pass
85
+ return _TOKEN_RE.findall(text)
86
+
87
+
88
+ def stopwords_en() -> frozenset:
89
+ """Return the English stop-word set (NLTK corpus when available)."""
90
+ if nltk is not None:
91
+ try:
92
+ return frozenset(nltk.corpus.stopwords.words("english")) # type: ignore[union-attr]
93
+ except LookupError:
94
+ try:
95
+ nltk.download("stopwords", quiet=True) # type: ignore[union-attr]
96
+ return frozenset(nltk.corpus.stopwords.words("english")) # type: ignore[union-attr]
97
+ except Exception:
98
+ pass
99
+ return _FALLBACK_STOPWORDS
100
+
101
+
102
+ class _SimpleStemmer:
103
+ """Very small suffix stripper used when NLTK is not importable."""
104
+
105
+ _SUFFIXES = (
106
+ "ational", "ization", "iveness", "ingly", "edly", "ments",
107
+ "ing", "ies", "ied", "ed", "es", "s",
108
+ )
109
+
110
+ def stem(self, word: str) -> str:
111
+ low = word.lower()
112
+ for suffix in self._SUFFIXES:
113
+ if len(low) > len(suffix) + 2 and low.endswith(suffix):
114
+ if suffix == "ies":
115
+ return low[: -len(suffix)] + "y"
116
+ return low[: -len(suffix)]
117
+ return low
118
+
119
+
120
+ def stemmer():
121
+ """Return a Porter stemmer (NLTK) or a minimal fallback stemmer."""
122
+ if nltk is not None:
123
+ try:
124
+ from nltk.stem import PorterStemmer
125
+
126
+ return PorterStemmer()
127
+ except Exception: # pragma: no cover
128
+ pass
129
+ return _SimpleStemmer()