Skip to content

Commit 6692ef4

Browse files
committed
Scaffold textkit: readability, slugify, and stats modules with pytest suite
Python reference example for Coverage Tracker's generating-coverage-reports guide; reports coverage and radon complexity to the demo instance.
0 parents  commit 6692ef4

11 files changed

Lines changed: 359 additions & 0 deletions

File tree

.github/workflows/coverage.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: coverage
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
permissions:
7+
id-token: write # OIDC → Worker
8+
checks: write # PR Check Run
9+
jobs:
10+
coverage:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: '3.12'
18+
19+
- run: pip install -e ".[dev]"
20+
21+
- run: coverage run -m pytest && coverage lcov -o coverage.lcov
22+
23+
- run: radon cc -j textkit > radon.json
24+
25+
- uses: CoverageTracker/coverage-tracker/.github/actions/report@v0.2.0
26+
with:
27+
worker-url: https://demo.coveragetracker.dev

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
__pycache__/
2+
*.pyc
3+
.coverage
4+
coverage.lcov
5+
radon.json
6+
htmlcov/
7+
*.egg-info/
8+
.pytest_cache/
9+
build/
10+
dist/
11+
.venv/
12+
venv/

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# example-python
2+
3+
[![coverage badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fdemo.coveragetracker.dev%2Fapi%2Fbadge%2FCoverageTracker%2Fexample-python%2Fcoverage.json)](https://demo.coveragetracker.dev/CoverageTracker/example-python?metric=coverage)
4+
[![complexity badge](https://img.shields.io/endpoint?url=https%3A%2F%2Fdemo.coveragetracker.dev%2Fapi%2Fbadge%2FCoverageTracker%2Fexample-python%2Fcomplexity.json)](https://demo.coveragetracker.dev/CoverageTracker/example-python?metric=complexity)
5+
6+
A small, idiomatic Python text-analysis toolkit used as the Python reference
7+
example for [Coverage Tracker](https://coveragetracker.dev). It exists to
8+
give the Python row in the
9+
[coverage report generation guide](https://coveragetracker.dev/docs/generating-coverage-reports)
10+
a live, working reference, and to populate the
11+
[demo dashboard](https://demo.coveragetracker.dev) with real trend data.
12+
13+
**This is a demo/marketing repo, not a test suite for Coverage Tracker
14+
itself.**
15+
16+
## What's here
17+
18+
- `textkit/` — readability scoring, slug generation, and word-frequency
19+
helpers, each with real branching logic.
20+
- `tests/` — a [pytest](https://pytest.org) suite with a few deliberately
21+
uncovered branches, so `branch_coverage < line_coverage` shows up on the
22+
dashboard.
23+
- `.github/workflows/coverage.yml` — runs tests under
24+
[coverage.py](https://coverage.readthedocs.io), generates a
25+
[Radon](https://radon.readthedocs.io) complexity report, then reports both
26+
to the demo instance via the `coverage-tracker` reporting Action.
27+
28+
## Running locally
29+
30+
```sh
31+
pip install -e ".[dev]"
32+
coverage run -m pytest && coverage lcov -o coverage.lcov # writes coverage.lcov
33+
radon cc -j textkit > radon.json
34+
```

pyproject.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[project]
2+
name = "textkit"
3+
version = "0.1.0"
4+
description = "Small text-analysis toolkit — Python reference example for Coverage Tracker."
5+
requires-python = ">=3.10"
6+
dependencies = []
7+
8+
[project.optional-dependencies]
9+
dev = ["pytest>=8", "coverage>=7", "radon>=6"]
10+
11+
[tool.setuptools]
12+
packages = ["textkit"]
13+
14+
[tool.coverage.run]
15+
source = ["textkit"]
16+
branch = true
17+
18+
[build-system]
19+
requires = ["setuptools>=68"]
20+
build-backend = "setuptools.build_meta"

tests/test_readability.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pytest
2+
3+
from textkit.readability import count_syllables, flesch_kincaid_grade, grade_label
4+
5+
6+
def test_count_syllables_simple_word():
7+
assert count_syllables("cat") == 1
8+
9+
10+
def test_count_syllables_silent_e_is_trimmed():
11+
assert count_syllables("bike") == 1
12+
13+
14+
def test_count_syllables_le_ending_not_trimmed():
15+
assert count_syllables("apple") == 2
16+
17+
18+
def test_count_syllables_blank_string():
19+
assert count_syllables(" ") == 0
20+
21+
22+
def test_flesch_kincaid_grade_simple_text_scores_low():
23+
grade = flesch_kincaid_grade("The cat sat on the mat. It was happy.")
24+
assert grade < 5
25+
26+
27+
def test_flesch_kincaid_grade_raises_on_empty_text():
28+
with pytest.raises(ValueError):
29+
flesch_kincaid_grade(" ... ")
30+
31+
32+
@pytest.mark.parametrize(
33+
"grade,expected",
34+
[
35+
(2, "Elementary school"),
36+
(7, "Middle school"),
37+
(10, "High school"),
38+
(14, "College"),
39+
(20, "Graduate"),
40+
],
41+
)
42+
def test_grade_label_bands(grade, expected):
43+
assert grade_label(grade) == expected

tests/test_slugify.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import pytest
2+
3+
from textkit.slugify import is_slug, slugify
4+
5+
6+
def test_slugify_basic_text():
7+
assert slugify("Hello, World!") == "hello-world"
8+
9+
10+
def test_slugify_collapses_repeated_separators():
11+
assert slugify(" multiple spaces--and--dashes ") == "multiple-spaces-and-dashes"
12+
13+
14+
def test_slugify_strips_accents():
15+
assert slugify("Café Münchën") == "cafe-munchen"
16+
17+
18+
def test_slugify_raises_when_nothing_sluggable():
19+
with pytest.raises(ValueError):
20+
slugify("!!!???")
21+
22+
23+
def test_is_slug_valid():
24+
assert is_slug("hello-world") is True
25+
26+
27+
def test_is_slug_rejects_uppercase():
28+
assert is_slug("Hello-World") is False
29+
30+
31+
def test_is_slug_empty_string():
32+
assert is_slug("") is False

tests/test_stats.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import pytest
2+
3+
from textkit.stats import most_common_word, tokenize, word_frequencies
4+
5+
6+
def test_tokenize_lowercases_and_strips_punctuation():
7+
assert tokenize("Hello, World! It's great.") == ["hello", "world", "it's", "great"]
8+
9+
10+
def test_word_frequencies_excludes_default_stopwords():
11+
text = "the cat and the dog and the cat"
12+
assert word_frequencies(text, top_n=2) == [("cat", 2), ("dog", 1)]
13+
14+
15+
def test_word_frequencies_with_custom_stopwords():
16+
result = word_frequencies("red green red blue", stopwords={"red"})
17+
assert result == [("green", 1), ("blue", 1)]
18+
19+
20+
def test_word_frequencies_returns_empty_when_all_stopwords():
21+
assert word_frequencies("the a an") == []
22+
23+
24+
def test_word_frequencies_raises_on_non_positive_top_n():
25+
with pytest.raises(ValueError):
26+
word_frequencies("hello world", top_n=0)
27+
28+
29+
def test_most_common_word_returns_top_word():
30+
assert most_common_word("dog dog cat") == "dog"
31+
32+
33+
def test_most_common_word_returns_none_for_empty_result():
34+
assert most_common_word("the a an") is None

textkit/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""textkit — a small text-analysis toolkit.
2+
3+
Readability scoring, slug generation, and word-frequency statistics.
4+
"""
5+
6+
from textkit.readability import flesch_kincaid_grade, grade_label
7+
from textkit.slugify import is_slug, slugify
8+
from textkit.stats import most_common_word, word_frequencies
9+
10+
__all__ = [
11+
"flesch_kincaid_grade",
12+
"grade_label",
13+
"is_slug",
14+
"slugify",
15+
"most_common_word",
16+
"word_frequencies",
17+
]

textkit/readability.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Flesch-Kincaid style readability scoring for plain text."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
8+
def count_syllables(word: str) -> int:
9+
"""Rough heuristic syllable count for a single word."""
10+
word = word.lower().strip()
11+
if not word:
12+
return 0
13+
14+
groups = re.findall(r"[aeiouy]+", word)
15+
count = len(groups)
16+
17+
if word.endswith("e") and not word.endswith("le") and count > 1:
18+
count -= 1
19+
20+
return max(count, 1)
21+
22+
23+
def flesch_kincaid_grade(text: str) -> float:
24+
"""Compute the Flesch-Kincaid grade level for `text`.
25+
26+
Raises ValueError if the text has no scorable sentences or words.
27+
"""
28+
sentences = [s for s in re.split(r"[.!?]+", text) if s.strip()]
29+
words = re.findall(r"[A-Za-z']+", text)
30+
31+
if not sentences or not words:
32+
raise ValueError("text must contain at least one sentence and word")
33+
34+
syllables = sum(count_syllables(w) for w in words)
35+
36+
words_per_sentence = len(words) / len(sentences)
37+
syllables_per_word = syllables / len(words)
38+
39+
grade = 0.39 * words_per_sentence + 11.8 * syllables_per_word - 15.59
40+
return round(grade, 2)
41+
42+
43+
def grade_label(grade: float) -> str:
44+
"""Map a numeric grade level to a human-readable band."""
45+
if grade < 0:
46+
return "Below grade level"
47+
if grade <= 5:
48+
return "Elementary school"
49+
if grade <= 8:
50+
return "Middle school"
51+
if grade <= 12:
52+
return "High school"
53+
if grade <= 16:
54+
return "College"
55+
return "Graduate"

textkit/slugify.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""URL-safe slug generation."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import unicodedata
7+
8+
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
9+
10+
11+
def slugify(text: str, max_length: int = 50) -> str:
12+
"""Convert `text` into a lowercase, hyphenated URL slug.
13+
14+
Truncates to `max_length` without cutting a word in half. Raises
15+
ValueError if `max_length` isn't positive or the result would be empty.
16+
"""
17+
if max_length <= 0:
18+
raise ValueError("max_length must be positive")
19+
20+
normalized = unicodedata.normalize("NFKD", text)
21+
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
22+
slug = _NON_ALNUM.sub("-", ascii_text.lower()).strip("-")
23+
24+
if not slug:
25+
raise ValueError("text has no sluggable characters")
26+
27+
if len(slug) <= max_length:
28+
return slug
29+
30+
truncated = slug[:max_length]
31+
last_hyphen = truncated.rfind("-")
32+
if last_hyphen > 0:
33+
truncated = truncated[:last_hyphen]
34+
35+
return truncated.strip("-")
36+
37+
38+
def is_slug(value: str) -> bool:
39+
"""Return True if `value` is already a valid slug."""
40+
if not value:
41+
return False
42+
return bool(re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", value))

0 commit comments

Comments
 (0)