Skip to content

Commit 3898ade

Browse files
committed
added more helpers
1 parent cf9af6c commit 3898ade

5 files changed

Lines changed: 246 additions & 33 deletions

File tree

README.md

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,45 +12,66 @@ Stop typing `(part / whole) * 100` and worrying about division by zero.
1212

1313
## ✨ What It Does
1414

15-
Percentify gives you a single function:
15+
A tiny, zero-dependency Python toolkit for all things percentages:
1616

17-
- Calculates what percentage one number is of another.
18-
- Handles divide-by-zero safely (returns 0.0 instead of crashing).
19-
- Lets you choose how many decimal places to round to.
20-
- Has zero dependencies — just pure Python.
17+
- **`percent`** — what percentage is `part` of `whole`?
18+
- **`percent_change`** — how much did a value increase or decrease?
19+
- **`percent_diff`** — how far apart are two values?
20+
- **`percent_distribute`** — split a total into weighted shares.
21+
- **`percent_format`** — turn any number into a clean `"25.0%"` string.
22+
23+
All functions handle edge cases (division by zero, negative values) safely and let you control decimal precision.
2124

2225
## 📦 Installation
2326
```
2427
pip install percentify
2528
```
2629

27-
### Usage
28-
```
30+
## Usage
31+
32+
### `percent` — Part of a Whole
33+
```python
2934
from percentify import percent
3035

31-
# Basic usage
3236
percent(50, 200) # → 25.0
33-
34-
# Handles fractions
3537
percent(1, 3) # → 33.33
38+
percent(5, 0) # → 0.0 (safe division by zero)
39+
percent(7, 9, 4) # → 77.7778 (custom decimals)
40+
```
3641

37-
# Safe when dividing by zero
38-
percent(5, 0) # → 0.0
42+
### `percent_change` — Increase or Decrease
43+
```python
44+
from percentify import percent_change
3945

40-
# Custom decimals
41-
percent(7, 9, 4) # → 77.7778
46+
percent_change(100, 150) # → 50.0 (50% increase)
47+
percent_change(200, 150) # → -25.0 (25% decrease)
48+
percent_change(0, 100) # → 0.0 (safe when old is zero)
4249
```
4350

44-
### 🛠️ How It Works
51+
### `percent_diff` — Difference Between Two Values
52+
```python
53+
from percentify import percent_diff
4554

46-
The library is intentionally simple;
55+
percent_diff(10, 20) # → 66.67
56+
percent_diff(50, 50) # → 0.0
4757
```
48-
def percent(part: float, whole: float, decimals: int = 2) -> float:
49-
if whole == 0:
50-
return 0.0
51-
return round((part / whole) * 100, decimals)
58+
59+
### `percent_distribute` — Split a Total by Weights
60+
```python
61+
from percentify import percent_distribute
62+
63+
percent_distribute(200, [1, 3]) # → [50.0, 150.0]
64+
percent_distribute(100, [1, 1, 1]) # → [33.33, 33.33, 33.33]
65+
```
66+
67+
### `percent_format` — Format as a String
68+
```python
69+
from percentify import percent_format
70+
71+
percent_format(25.0) # → "25.0%"
72+
percent_format(33.3333, 1) # → "33.3%"
73+
percent_format(50, suffix=" percent") # → "50.0 percent"
5274
```
53-
That’s it. Clean, safe, and ready to use anywhere you need percentages in your code.
5475

5576
# 🤝 Contributing
5677

percentify/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .core import percent
1+
from .core import percent, percent_change, percent_diff, percent_distribute, percent_format
22

3-
__all__ = ["percent"]
4-
__version__ = "0.1.3"
3+
__all__ = ["percent", "percent_change", "percent_diff", "percent_distribute", "percent_format"]
4+
__version__ = "0.2.0"

percentify/core.py

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
from typing import SupportsFloat, Optional
1+
from typing import List, SupportsFloat, Optional
2+
3+
4+
def _round_value(value: float, decimals: Optional[int]) -> float:
5+
if decimals is None:
6+
return value
7+
if decimals < 0:
8+
raise ValueError("`decimals` must be non-negative or None.")
9+
return round(value, decimals)
10+
211

312
def percent(part: SupportsFloat, whole: SupportsFloat, decimals: Optional[int] = 2) -> float:
413
"""
@@ -18,11 +27,97 @@ def percent(part: SupportsFloat, whole: SupportsFloat, decimals: Optional[int] =
1827
return 0.0
1928

2029
value = float(part) / whole * 100.0
30+
return _round_value(value, decimals)
2131

22-
if decimals is None:
23-
return value
2432

25-
if decimals < 0:
26-
raise ValueError("`decimals` must be non-negative or None.")
33+
def percent_change(old: SupportsFloat, new: SupportsFloat, decimals: Optional[int] = 2) -> float:
34+
"""
35+
Calculate the percentage change from `old` to `new`.
2736
28-
return round(value, decimals)
37+
Args:
38+
old: The original value.
39+
new: The new value.
40+
decimals: Number of decimal places to round to.
41+
If None, the raw percentage (unrounded float) is returned.
42+
43+
Returns:
44+
float: Percentage change. Positive means increase, negative means decrease.
45+
If `old` is 0, returns 0.0.
46+
"""
47+
old = float(old)
48+
if old == 0:
49+
return 0.0
50+
51+
value = (float(new) - old) / abs(old) * 100.0
52+
return _round_value(value, decimals)
53+
54+
55+
def percent_diff(a: SupportsFloat, b: SupportsFloat, decimals: Optional[int] = 2) -> float:
56+
"""
57+
Calculate the percentage difference between two values.
58+
59+
Uses the average of the two values as the denominator.
60+
61+
Args:
62+
a: First value.
63+
b: Second value.
64+
decimals: Number of decimal places to round to.
65+
If None, the raw percentage (unrounded float) is returned.
66+
67+
Returns:
68+
float: Percentage difference (always non-negative).
69+
If both values are 0, returns 0.0.
70+
"""
71+
a, b = float(a), float(b)
72+
avg = (abs(a) + abs(b)) / 2.0
73+
if avg == 0:
74+
return 0.0
75+
76+
value = abs(a - b) / avg * 100.0
77+
return _round_value(value, decimals)
78+
79+
80+
def percent_distribute(total: SupportsFloat, weights: List[SupportsFloat], decimals: Optional[int] = 2) -> List[float]:
81+
"""
82+
Distribute a total into percentage-based shares according to weights.
83+
84+
Args:
85+
total: The total value to distribute.
86+
weights: A list of weights determining each share's proportion.
87+
decimals: Number of decimal places to round to.
88+
If None, the raw values (unrounded floats) are returned.
89+
90+
Returns:
91+
list[float]: Each weight's share of the total.
92+
93+
Raises:
94+
ValueError: If weights is empty or all weights are zero.
95+
"""
96+
if not weights:
97+
raise ValueError("`weights` must not be empty.")
98+
99+
float_weights = [float(w) for w in weights]
100+
weight_sum = sum(float_weights)
101+
102+
if weight_sum == 0:
103+
raise ValueError("Sum of `weights` must not be zero.")
104+
105+
total = float(total)
106+
return [_round_value(w / weight_sum * total, decimals) for w in float_weights]
107+
108+
109+
def percent_format(value: SupportsFloat, decimals: Optional[int] = 2, suffix: str = "%") -> str:
110+
"""
111+
Format a numeric value as a percentage string.
112+
113+
Args:
114+
value: The percentage value to format.
115+
decimals: Number of decimal places to round to.
116+
If None, the raw float is used without rounding.
117+
suffix: The suffix to append (default: "%").
118+
119+
Returns:
120+
str: Formatted percentage string, e.g. "25.0%".
121+
"""
122+
rounded = _round_value(float(value), decimals)
123+
return f"{rounded}{suffix}"

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "percentify"
7-
version = "0.1.4"
8-
description = "A micro-library to easily calculate percentage between two numbers."
7+
version = "0.2.0"
8+
description = "A tiny, zero-dependency Python toolkit for percentages — calculate, compare, format, and distribute."
99
authors = [{ name = "Daniel Emakporuena", email = "danielemakporuena1@gmail.com" }]
1010
readme = "README.md"
1111
license = { text = "Apache-2.0" }
1212
requires-python = ">=3.7"
13+
keywords = ["percentage", "percent", "math", "utility", "statistics", "format"]
1314
classifiers = [
1415
"Programming Language :: Python :: 3",
1516
"License :: OSI Approved :: Apache Software License",

tests/test_core.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
from percentify import percent
1+
import pytest
2+
from percentify import percent, percent_change, percent_diff, percent_distribute, percent_format
3+
4+
5+
# --- percent ---
26

37
def test_percent_normal():
48
assert percent(50, 200) == 25.0
@@ -8,3 +12,95 @@ def test_percent_fraction():
812

913
def test_percent_zero_division():
1014
assert percent(5, 0) == 0.0
15+
16+
def test_percent_no_rounding():
17+
assert percent(1, 3, None) == pytest.approx(33.33333333333333)
18+
19+
def test_percent_custom_decimals():
20+
assert percent(7, 9, 4) == 77.7778
21+
22+
def test_percent_negative_decimals():
23+
with pytest.raises(ValueError):
24+
percent(1, 3, -1)
25+
26+
27+
# --- percent_change ---
28+
29+
def test_percent_change_increase():
30+
assert percent_change(100, 150) == 50.0
31+
32+
def test_percent_change_decrease():
33+
assert percent_change(200, 150) == -25.0
34+
35+
def test_percent_change_no_change():
36+
assert percent_change(100, 100) == 0.0
37+
38+
def test_percent_change_zero_old():
39+
assert percent_change(0, 100) == 0.0
40+
41+
def test_percent_change_negative_old():
42+
assert percent_change(-100, -50) == 50.0
43+
44+
def test_percent_change_custom_decimals():
45+
assert percent_change(3, 7, 4) == 133.3333
46+
47+
48+
# --- percent_diff ---
49+
50+
def test_percent_diff_basic():
51+
assert percent_diff(10, 20) == 66.67
52+
53+
def test_percent_diff_same():
54+
assert percent_diff(50, 50) == 0.0
55+
56+
def test_percent_diff_both_zero():
57+
assert percent_diff(0, 0) == 0.0
58+
59+
def test_percent_diff_order_independent():
60+
assert percent_diff(10, 20) == percent_diff(20, 10)
61+
62+
def test_percent_diff_custom_decimals():
63+
assert percent_diff(3, 7, 4) == 80.0
64+
65+
66+
# --- percent_distribute ---
67+
68+
def test_distribute_equal():
69+
assert percent_distribute(100, [1, 1, 1]) == [pytest.approx(33.33), pytest.approx(33.33), pytest.approx(33.33)]
70+
71+
def test_distribute_weighted():
72+
assert percent_distribute(200, [1, 3]) == [50.0, 150.0]
73+
74+
def test_distribute_single():
75+
assert percent_distribute(100, [5]) == [100.0]
76+
77+
def test_distribute_empty():
78+
with pytest.raises(ValueError):
79+
percent_distribute(100, [])
80+
81+
def test_distribute_zero_weights():
82+
with pytest.raises(ValueError):
83+
percent_distribute(100, [0, 0])
84+
85+
def test_distribute_custom_decimals():
86+
result = percent_distribute(100, [1, 1, 1], 4)
87+
assert result == [pytest.approx(33.3333), pytest.approx(33.3333), pytest.approx(33.3333)]
88+
89+
90+
# --- percent_format ---
91+
92+
def test_format_basic():
93+
assert percent_format(25.0) == "25.0%"
94+
95+
def test_format_custom_decimals():
96+
assert percent_format(33.3333, 1) == "33.3%"
97+
98+
def test_format_custom_suffix():
99+
assert percent_format(50, suffix=" percent") == "50.0 percent"
100+
101+
def test_format_no_rounding():
102+
result = percent_format(33.3333, None)
103+
assert result == "33.3333%"
104+
105+
def test_format_zero():
106+
assert percent_format(0) == "0.0%"

0 commit comments

Comments
 (0)