chiranjeev-toolkit 1.0.0__py3-none-any.whl

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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: chiranjeev-toolkit
3
+ Version: 1.0.0
4
+ Summary: A tiny, dependency-free Python toolkit with math, text and statistics helpers, plus a beautiful terminal UI.
5
+ Author: Chiranjeev
6
+ License: MIT
7
+ Keywords: toolkit,math,text,statistics,cli,assignment
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Intended Audience :: Education
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ <div align="center">
18
+
19
+ # ๐Ÿงฐ chiranjeev-toolkit
20
+
21
+ **A tiny, dependency-free Python toolkit โ€” math, text & statistics helpers with a beautiful terminal UI.**
22
+
23
+ ![python](https://img.shields.io/badge/python-3.8%2B-blue)
24
+ ![license](https://img.shields.io/badge/license-MIT-green)
25
+ ![deps](https://img.shields.io/badge/dependencies-zero-brightgreen)
26
+
27
+ Made by **Chiranjeev** ยท Roll No. **2432709**
28
+
29
+ </div>
30
+
31
+ ---
32
+
33
+ ## โœจ Features
34
+
35
+ | Module | What it gives you |
36
+ | --- | --- |
37
+ | `ctoolkit.mathx` | `is_prime`, `primes_upto`, `prime_factors`, `fibonacci`, `factorial`, `gcd`, `lcm`, `collatz_steps` |
38
+ | `ctoolkit.textx` | `word_count`, `vowel_count`, `is_palindrome`, `reverse_words`, `caesar`, `slugify` |
39
+ | `ctoolkit.stats` | `mean`, `median`, `mode`, `variance`, `std_dev`, `describe` |
40
+ | `ctoolkit.display` | `box`, `table`, `banner`, `paint` โ€” ANSI colours that respect `NO_COLOR` |
41
+
42
+ ## ๐Ÿ“ฆ Installation
43
+
44
+ ```bash
45
+ pip install chiranjeev-toolkit
46
+ # or from source
47
+ pip install .
48
+ ```
49
+
50
+ ## ๐Ÿš€ Quick start
51
+
52
+ ```python
53
+ import ctoolkit as ct
54
+
55
+ ct.is_prime(97) # True
56
+ ct.prime_factors(360) # [2, 2, 2, 3, 3, 5]
57
+ ct.fibonacci(8) # [0, 1, 1, 2, 3, 5, 8, 13]
58
+ ct.caesar("Hello", 3) # 'Khoor'
59
+ ct.describe([2, 4, 4, 4, 5, 5, 7, 9])["std_dev"] # 2.0
60
+ ```
61
+
62
+ ## ๐Ÿ–ฅ๏ธ Command line
63
+
64
+ ```bash
65
+ ctoolkit info # banner + author details
66
+ ctoolkit prime 97 # primality + factorisation
67
+ ctoolkit fib 10 # first 10 Fibonacci numbers
68
+ ctoolkit primes 50 # all primes up to 50
69
+ ctoolkit stats 2 4 4 4 5 5 7 9 # descriptive statistics table
70
+ ctoolkit text "Never odd or even"
71
+ ```
72
+
73
+ (`python -m ctoolkit ...` works too.)
74
+
75
+ ## ๐Ÿงช Run the tests
76
+
77
+ ```bash
78
+ python -m unittest discover -s tests
79
+ ```
80
+
81
+ ## ๐Ÿ“ค Publishing to PyPI
82
+
83
+ ```bash
84
+ pip install build twine
85
+ python -m build
86
+ twine upload dist/*
87
+ ```
88
+
89
+ ## ๐Ÿ“„ License
90
+
91
+ MIT ยฉ 2026 Chiranjeev (Roll No. 2432709)
@@ -0,0 +1,13 @@
1
+ chiranjeev_toolkit-1.0.0.dist-info/licenses/LICENSE,sha256=lGBeIh4sM-xPUqoOwulvn8vziKquY5hAsT8ox2nDDvc,1086
2
+ ctoolkit/__init__.py,sha256=4ZDqJgotj-pumTLBaHbus8udQHUpEH2kG7YvYbUpUm4,838
3
+ ctoolkit/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
4
+ ctoolkit/cli.py,sha256=YYEMPHaAuL3gY6IS6CUhlkGTTDuYvve886LPbVn-t18,3268
5
+ ctoolkit/display.py,sha256=obXllZ7Qd6-jm6mCh-Vzacm3pVkJmHt8jgfO7bNuqyc,2777
6
+ ctoolkit/mathx.py,sha256=v3bD8xt_wL6I9DGwc7xlANjLvCduOs7SgTGpOiDGtac,2577
7
+ ctoolkit/stats.py,sha256=VV_3wbfE6yohcJEuvyIT1uAagfMXQ-wxAQdhgR_wVfk,1686
8
+ ctoolkit/textx.py,sha256=X6rPHhqNe-DmheRgHZ_K77ul3bgN55eigKXoGEv3MM4,1207
9
+ chiranjeev_toolkit-1.0.0.dist-info/METADATA,sha256=Nbe7LJIwQN7zs2fZnq0o6YxPcE7SdqPrZvYKuoStWEc,2582
10
+ chiranjeev_toolkit-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ chiranjeev_toolkit-1.0.0.dist-info/entry_points.txt,sha256=L4xgnSFnPiGL_XyMXcZf1KlVoKg5zSI6ZC1uUeUZU-U,47
12
+ chiranjeev_toolkit-1.0.0.dist-info/top_level.txt,sha256=wEHiB-U0_R1aiDKUc4VuYE2s3yQyUdyffIg4uPr1g4M,9
13
+ chiranjeev_toolkit-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctoolkit = ctoolkit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chiranjeev (Roll No. 2432709)
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.
@@ -0,0 +1 @@
1
+ ctoolkit
ctoolkit/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """ctoolkit - a tiny toolkit of math, text and statistics helpers.
2
+
3
+ Author : Chiranjeev
4
+ Roll No: 2432709
5
+ """
6
+
7
+ from .mathx import (
8
+ collatz_steps,
9
+ factorial,
10
+ fibonacci,
11
+ gcd,
12
+ is_prime,
13
+ lcm,
14
+ prime_factors,
15
+ primes_upto,
16
+ )
17
+ from .stats import describe, mean, median, mode, std_dev, variance
18
+ from .textx import (
19
+ caesar,
20
+ is_palindrome,
21
+ reverse_words,
22
+ slugify,
23
+ vowel_count,
24
+ word_count,
25
+ )
26
+
27
+ __version__ = "1.0.0"
28
+ __author__ = "Chiranjeev"
29
+ __roll_no__ = "2432709"
30
+
31
+ __all__ = [
32
+ "__version__", "__author__", "__roll_no__",
33
+ "collatz_steps", "factorial", "fibonacci", "gcd", "is_prime", "lcm",
34
+ "prime_factors", "primes_upto",
35
+ "describe", "mean", "median", "mode", "std_dev", "variance",
36
+ "caesar", "is_palindrome", "reverse_words", "slugify", "vowel_count",
37
+ "word_count",
38
+ ]
ctoolkit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
ctoolkit/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """Command-line interface: ``ctoolkit <command>`` or ``python -m ctoolkit``."""
2
+
3
+ import argparse
4
+ from typing import List, Optional
5
+
6
+ from . import __version__, mathx, stats, textx
7
+ from .display import banner, box, paint, table
8
+
9
+
10
+ def _build_parser() -> argparse.ArgumentParser:
11
+ p = argparse.ArgumentParser(
12
+ prog="ctoolkit",
13
+ description="Math, text and statistics helpers with a pretty terminal UI.",
14
+ )
15
+ p.add_argument("-V", "--version", action="version", version=f"ctoolkit {__version__}")
16
+ sub = p.add_subparsers(dest="command")
17
+
18
+ sub.add_parser("info", help="show the banner and author details")
19
+
20
+ s = sub.add_parser("prime", help="primality check and factorisation")
21
+ s.add_argument("n", type=int)
22
+
23
+ s = sub.add_parser("fib", help="first N Fibonacci numbers")
24
+ s.add_argument("count", type=int)
25
+
26
+ s = sub.add_parser("primes", help="all primes up to N")
27
+ s.add_argument("n", type=int)
28
+
29
+ s = sub.add_parser("stats", help="descriptive statistics for numbers")
30
+ s.add_argument("numbers", type=float, nargs="+")
31
+
32
+ s = sub.add_parser("text", help="analyse a piece of text")
33
+ s.add_argument("text", nargs="+")
34
+
35
+ return p
36
+
37
+
38
+ def _fmt(x: float) -> str:
39
+ return f"{x:.4g}" if isinstance(x, float) else str(x)
40
+
41
+
42
+ def main(argv: Optional[List[str]] = None) -> int:
43
+ parser = _build_parser()
44
+ args = parser.parse_args(argv)
45
+
46
+ try:
47
+ if args.command in (None, "info"):
48
+ print(banner())
49
+ if args.command is None:
50
+ print("\nRun " + paint("ctoolkit --help", "yellow") + " to see all commands.")
51
+ elif args.command == "prime":
52
+ ok = mathx.is_prime(args.n)
53
+ verdict = paint("PRIME โœ”", "green", "bold") if ok else paint("NOT PRIME โœ˜", "red", "bold")
54
+ lines = [f"{args.n} is {verdict}"]
55
+ if args.n >= 2:
56
+ lines.append("Factors: " + " ร— ".join(map(str, mathx.prime_factors(args.n))))
57
+ print(box(lines, "green" if ok else "red"))
58
+ elif args.command == "fib":
59
+ seq = mathx.fibonacci(args.count)
60
+ print(box([", ".join(map(str, seq)) or "(empty)"], "cyan"))
61
+ elif args.command == "primes":
62
+ ps = mathx.primes_upto(args.n)
63
+ print(box([f"{len(ps)} primes up to {args.n}", ", ".join(map(str, ps))], "blue"))
64
+ elif args.command == "stats":
65
+ d = stats.describe(args.numbers)
66
+ rows = [(k, ", ".join(map(_fmt, v)) if isinstance(v, list) else _fmt(v)) for k, v in d.items()]
67
+ print(table(["Metric", "Value"], rows, "magenta"))
68
+ elif args.command == "text":
69
+ t = " ".join(args.text)
70
+ rows = [
71
+ ("Words", textx.word_count(t)),
72
+ ("Vowels", textx.vowel_count(t)),
73
+ ("Palindrome", "yes" if textx.is_palindrome(t) else "no"),
74
+ ("Reversed words", textx.reverse_words(t)),
75
+ ("Slug", textx.slugify(t)),
76
+ ("Caesar +3", textx.caesar(t, 3)),
77
+ ]
78
+ print(table(["Property", "Result"], rows, "cyan"))
79
+ except (ValueError, TypeError) as exc:
80
+ print(paint(f"Error: {exc}", "red"))
81
+ return 1
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
ctoolkit/display.py ADDED
@@ -0,0 +1,80 @@
1
+ """Tiny ANSI helpers for a beautiful terminal UI (respects NO_COLOR)."""
2
+
3
+ import os
4
+ import sys
5
+ from typing import Iterable, List, Sequence
6
+
7
+ _CODES = {
8
+ "reset": "0", "bold": "1", "dim": "2",
9
+ "red": "91", "green": "92", "yellow": "93",
10
+ "blue": "94", "magenta": "95", "cyan": "96",
11
+ }
12
+
13
+
14
+ def _use_color() -> bool:
15
+ return sys.stdout.isatty() and "NO_COLOR" not in os.environ
16
+
17
+
18
+ def paint(text: str, *styles: str) -> str:
19
+ """Wrap ``text`` in ANSI styles (no-op when colour is unavailable)."""
20
+ if not _use_color():
21
+ return text
22
+ start = "".join(f"\033[{_CODES[s]}m" for s in styles)
23
+ return f"{start}{text}\033[0m"
24
+
25
+
26
+ def box(lines: Iterable[str], color: str = "cyan") -> str:
27
+ """Draw a rounded box around ``lines``."""
28
+ rows = list(lines)
29
+ width = max((len(r) for r in rows), default=0)
30
+ top = paint("โ•ญ" + "โ”€" * (width + 2) + "โ•ฎ", color)
31
+ bottom = paint("โ•ฐ" + "โ”€" * (width + 2) + "โ•ฏ", color)
32
+ side = paint("โ”‚", color)
33
+ body = [f"{side} {r.ljust(width)} {side}" for r in rows]
34
+ return "\n".join([top, *body, bottom])
35
+
36
+
37
+ def table(headers: Sequence[str], rows: Sequence[Sequence[object]], color: str = "cyan") -> str:
38
+ """Render a neat unicode table."""
39
+ str_rows: List[List[str]] = [[str(c) for c in r] for r in rows]
40
+ widths = [
41
+ max(len(h), *(len(r[i]) for r in str_rows)) if str_rows else len(h)
42
+ for i, h in enumerate(headers)
43
+ ]
44
+
45
+ def line(left: str, mid: str, right: str) -> str:
46
+ return paint(left + mid.join("โ”€" * (w + 2) for w in widths) + right, color)
47
+
48
+ def row(cells: Sequence[str], style: str = "") -> str:
49
+ bar = paint("โ”‚", color)
50
+ parts = [f" {paint(c.ljust(w), style) if style else c.ljust(w)} " for c, w in zip(cells, widths)]
51
+ return bar + bar.join(parts) + bar
52
+
53
+ out = [line("โ”Œ", "โ”ฌ", "โ”"), row(list(headers), "bold"), line("โ”œ", "โ”ผ", "โ”ค")]
54
+ out += [row(r) for r in str_rows]
55
+ out.append(line("โ””", "โ”ด", "โ”˜"))
56
+ return "\n".join(out)
57
+
58
+
59
+ def banner() -> str:
60
+ """The package banner."""
61
+ from . import __author__, __roll_no__, __version__
62
+
63
+ art = [
64
+ " ____ _____ ___ ___ _ _ _____ _____ ",
65
+ " / ___|_ _/ _ \\ / _ \\| | | |/ /_ _|_ _|",
66
+ "| | | || | | | | | | | | ' / | | | | ",
67
+ "| |___ | || |_| | |_| | |___| . \\ | | | | ",
68
+ " \\____| |_| \\___/ \\___/|_____|_|\\_\\___| |_| ",
69
+ ]
70
+ colors = ["cyan", "cyan", "blue", "blue", "magenta"]
71
+ art_block = "\n".join(paint(l, c, "bold") for l, c in zip(art, colors))
72
+ info = box(
73
+ [
74
+ f"Author : {__author__}",
75
+ f"Roll No : {__roll_no__}",
76
+ f"Version : {__version__}",
77
+ ],
78
+ "magenta",
79
+ )
80
+ return f"{art_block}\n{info}"
ctoolkit/mathx.py ADDED
@@ -0,0 +1,101 @@
1
+ """Number-theory and math helpers."""
2
+
3
+ from typing import List
4
+
5
+
6
+ def _check_int(n: int, name: str = "n", minimum: int = 0) -> None:
7
+ if not isinstance(n, int) or isinstance(n, bool):
8
+ raise TypeError(f"{name} must be an int, got {type(n).__name__}")
9
+ if n < minimum:
10
+ raise ValueError(f"{name} must be >= {minimum}, got {n}")
11
+
12
+
13
+ def is_prime(n: int) -> bool:
14
+ """Return True if ``n`` is a prime number."""
15
+ _check_int(n, minimum=-(10**18))
16
+ if n < 2:
17
+ return False
18
+ if n < 4:
19
+ return True
20
+ if n % 2 == 0 or n % 3 == 0:
21
+ return False
22
+ i = 5
23
+ while i * i <= n:
24
+ if n % i == 0 or n % (i + 2) == 0:
25
+ return False
26
+ i += 6
27
+ return True
28
+
29
+
30
+ def primes_upto(n: int) -> List[int]:
31
+ """Return all primes <= n using the Sieve of Eratosthenes."""
32
+ _check_int(n)
33
+ if n < 2:
34
+ return []
35
+ sieve = bytearray([1]) * (n + 1)
36
+ sieve[0:2] = b"\x00\x00"
37
+ for i in range(2, int(n ** 0.5) + 1):
38
+ if sieve[i]:
39
+ sieve[i * i :: i] = bytearray(len(range(i * i, n + 1, i)))
40
+ return [i for i, flag in enumerate(sieve) if flag]
41
+
42
+
43
+ def prime_factors(n: int) -> List[int]:
44
+ """Return the prime factorisation of ``n`` (with repetition)."""
45
+ _check_int(n, minimum=2)
46
+ factors, d = [], 2
47
+ while d * d <= n:
48
+ while n % d == 0:
49
+ factors.append(d)
50
+ n //= d
51
+ d += 1 if d == 2 else 2
52
+ if n > 1:
53
+ factors.append(n)
54
+ return factors
55
+
56
+
57
+ def fibonacci(count: int) -> List[int]:
58
+ """Return the first ``count`` Fibonacci numbers."""
59
+ _check_int(count, "count")
60
+ seq: List[int] = []
61
+ a, b = 0, 1
62
+ for _ in range(count):
63
+ seq.append(a)
64
+ a, b = b, a + b
65
+ return seq
66
+
67
+
68
+ def factorial(n: int) -> int:
69
+ """Return n! (iteratively, so no recursion limit)."""
70
+ _check_int(n)
71
+ result = 1
72
+ for i in range(2, n + 1):
73
+ result *= i
74
+ return result
75
+
76
+
77
+ def gcd(a: int, b: int) -> int:
78
+ """Greatest common divisor (Euclid's algorithm)."""
79
+ _check_int(a, "a", minimum=-(10**18))
80
+ _check_int(b, "b", minimum=-(10**18))
81
+ a, b = abs(a), abs(b)
82
+ while b:
83
+ a, b = b, a % b
84
+ return a
85
+
86
+
87
+ def lcm(a: int, b: int) -> int:
88
+ """Least common multiple."""
89
+ if a == 0 or b == 0:
90
+ return 0
91
+ return abs(a * b) // gcd(a, b)
92
+
93
+
94
+ def collatz_steps(n: int) -> int:
95
+ """Number of steps for ``n`` to reach 1 in the Collatz sequence."""
96
+ _check_int(n, minimum=1)
97
+ steps = 0
98
+ while n != 1:
99
+ n = n // 2 if n % 2 == 0 else 3 * n + 1
100
+ steps += 1
101
+ return steps
ctoolkit/stats.py ADDED
@@ -0,0 +1,59 @@
1
+ """Descriptive statistics without any third-party dependency."""
2
+
3
+ import math
4
+ from collections import Counter
5
+ from typing import Dict, List, Sequence
6
+
7
+
8
+ def _prepare(data: Sequence[float]) -> List[float]:
9
+ values = list(data)
10
+ if not values:
11
+ raise ValueError("data must not be empty")
12
+ return values
13
+
14
+
15
+ def mean(data: Sequence[float]) -> float:
16
+ values = _prepare(data)
17
+ return sum(values) / len(values)
18
+
19
+
20
+ def median(data: Sequence[float]) -> float:
21
+ values = sorted(_prepare(data))
22
+ mid = len(values) // 2
23
+ if len(values) % 2:
24
+ return float(values[mid])
25
+ return (values[mid - 1] + values[mid]) / 2
26
+
27
+
28
+ def mode(data: Sequence[float]) -> List[float]:
29
+ """Return every most-frequent value (sorted)."""
30
+ counts = Counter(_prepare(data))
31
+ top = max(counts.values())
32
+ return sorted(v for v, c in counts.items() if c == top)
33
+
34
+
35
+ def variance(data: Sequence[float], sample: bool = False) -> float:
36
+ values = _prepare(data)
37
+ if sample and len(values) < 2:
38
+ raise ValueError("sample variance needs at least two values")
39
+ m = mean(values)
40
+ ss = sum((x - m) ** 2 for x in values)
41
+ return ss / (len(values) - 1 if sample else len(values))
42
+
43
+
44
+ def std_dev(data: Sequence[float], sample: bool = False) -> float:
45
+ return math.sqrt(variance(data, sample))
46
+
47
+
48
+ def describe(data: Sequence[float]) -> Dict[str, object]:
49
+ """Return a summary dictionary of the data."""
50
+ values = _prepare(data)
51
+ return {
52
+ "count": len(values),
53
+ "min": min(values),
54
+ "max": max(values),
55
+ "mean": mean(values),
56
+ "median": median(values),
57
+ "mode": mode(values),
58
+ "std_dev": std_dev(values),
59
+ }
ctoolkit/textx.py ADDED
@@ -0,0 +1,42 @@
1
+ """Text-processing helpers."""
2
+
3
+ import re
4
+ import string
5
+
6
+
7
+ def word_count(text: str) -> int:
8
+ """Count the words in ``text``."""
9
+ return len(re.findall(r"\b[\w']+\b", text))
10
+
11
+
12
+ def vowel_count(text: str) -> int:
13
+ """Count the vowels (a, e, i, o, u) in ``text``."""
14
+ return sum(1 for ch in text.lower() if ch in "aeiou")
15
+
16
+
17
+ def is_palindrome(text: str) -> bool:
18
+ """Case-, space- and punctuation-insensitive palindrome check."""
19
+ cleaned = [ch.lower() for ch in text if ch.isalnum()]
20
+ return cleaned == cleaned[::-1]
21
+
22
+
23
+ def reverse_words(text: str) -> str:
24
+ """Reverse the order of the words in ``text``."""
25
+ return " ".join(reversed(text.split()))
26
+
27
+
28
+ def caesar(text: str, shift: int) -> str:
29
+ """Apply a Caesar cipher. Use a negative shift to decrypt."""
30
+ shift %= 26
31
+ lower, upper = string.ascii_lowercase, string.ascii_uppercase
32
+ table = str.maketrans(
33
+ lower + upper,
34
+ lower[shift:] + lower[:shift] + upper[shift:] + upper[:shift],
35
+ )
36
+ return text.translate(table)
37
+
38
+
39
+ def slugify(text: str) -> str:
40
+ """Turn ``text`` into a URL-friendly slug."""
41
+ text = re.sub(r"[^\w\s-]", "", text.lower())
42
+ return re.sub(r"[\s_-]+", "-", text).strip("-")