Skip to content

Commit c7dccdd

Browse files
authored
Merge pull request #1 from data-centt/main
Update
2 parents e24990e + a9cfa33 commit c7dccdd

5 files changed

Lines changed: 275 additions & 38 deletions

File tree

README.md

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,87 @@
11
# % Percentify %
2-
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/percentify?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/percentify)
32
[![PyPI version](https://img.shields.io/pypi/v/percentify.svg?style=flat&color=blue)](https://pypi.org/project/percentify/)
43
[![Python Versions](https://img.shields.io/pypi/pyversions/percentify.svg?style=flat&color=green)](https://pypi.org/project/percentify/)
54
[![License](https://img.shields.io/pypi/l/percentify.svg?style=flat&color=orange)](LICENSE)
65
[![Build Status](https://github.com/data-centt/percentify/actions/workflows/python-app.yml/badge.svg)](https://github.com/data-centt/percentify/actions/workflows/python-app.yml)
6+
![GitHub Stars](https://img.shields.io/github/stars/data-centt/percentify?style=flat&color=white)
77

8-
**Percentify** a tiny Python helper that turns *"part of a whole"* into a clean percentage.
8+
**Percentify** is a Python package that turns *"part of a whole"* into a clean percentage.
99
Stop typing `(part / whole) * 100` and worrying about division by zero.
1010

1111
---
1212

1313
## ✨ What It Does
1414

15-
Percentify gives you a single function:
15+
A zero-dependency Python toolkit for all things percentages:
16+
17+
18+
- **`percent`** — what percentage is `part` of `whole`?
19+
- **`change`** — how much did a value increase or decrease?
20+
- **`difference`** — how far apart are two values?
21+
- **`split`** — split a total into weighted shares.
22+
- **`display`** — turn any number into a clean `"25.0%"` string.
23+
24+
All functions handle edge cases (division by zero, negative values) safely and let you control decimal precision.
1625

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.
2126

2227
## 📦 Installation
2328
```
2429
pip install percentify
2530
```
2631

27-
### Usage
28-
```
32+
## Usage
33+
34+
### `percent` — Part of a Whole
35+
```python
2936
from percentify import percent
3037

31-
# Basic usage
3238
percent(50, 200) # → 25.0
33-
34-
# Handles fractions
3539
percent(1, 3) # → 33.33
40+
percent(5, 0) # → 0.0 (safe division by zero)
41+
percent(7, 9, 4) # → 77.7778 (custom decimals)
42+
```
3643

37-
# Safe when dividing by zero
38-
percent(5, 0) # → 0.0
44+
### `change` — Increase or Decrease
45+
```python
46+
from percentify import change
3947

40-
# Custom decimals
41-
percent(7, 9, 4) # → 77.7778
48+
change(100, 150) # → 50.0 (50% increase)
49+
change(200, 150) # → -25.0 (25% decrease)
50+
change(0, 100) # → 0.0 (safe when old is zero)
4251
```
4352

44-
### 🛠️ How It Works
53+
### `difference` — Difference Between Two Values
54+
```python
55+
from percentify import difference
4556

46-
The library is intentionally simple — just one function:
57+
difference(10, 20) # → 66.67
58+
difference(50, 50) # → 0.0
4759
```
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)
60+
61+
### `split` — Split a Total by Weights
62+
```python
63+
from percentify import split
64+
65+
split(200, [1, 3]) # → [50.0, 150.0]
66+
split(100, [1, 1, 1]) # → [33.33, 33.33, 33.33]
67+
```
68+
69+
### `display` — Format as a String
70+
```python
71+
from percentify import display
72+
73+
display(25.0) # → "25.0%"
74+
display(33.3333, 1) # → "33.3%"
75+
display(50, suffix=" percent") # → "50.0 percent"
76+
display(0.45, multiply=True) # → "45.0%"
77+
```
78+
79+
### Composing Functions
80+
```python
81+
from percentify import change, display
82+
83+
display(change(100, 20)) # → "-80.0%"
5284
```
53-
That’s it. Clean, safe, and ready to use anywhere you need percentages in your code.
5485

5586
# 🤝 Contributing
5687

@@ -61,4 +92,4 @@ Contributions are welcome!
6192
- Commit your changes
6293
- Open a pull request
6394

64-
I try to keep it tiny on purpose, to discuss big new features first.
95+
I try to keep it within scope, to discuss big new features first.

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, change, difference, split, display
22

3-
__all__ = ["percent"]
4-
__version__ = "0.1.3"
3+
__all__ = ["percent", "change", "difference", "split", "display"]
4+
__version__ = "0.3.1"

percentify/core.py

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
from typing import SupportsFloat
1+
from typing import List, SupportsFloat, Optional
22

3-
def percent(part: SupportsFloat, whole: SupportsFloat, decimals: int | None = 2) -> float:
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+
11+
12+
def percent(part: SupportsFloat, whole: SupportsFloat, decimals: Optional[int] = 2) -> float:
413
"""
5-
Calculate what percentage `part` is of the `whole`.
14+
Calculate what percentage as `part` is of the `whole`.
615
716
Args:
817
part: The numerator.
@@ -18,11 +27,102 @@ def percent(part: SupportsFloat, whole: SupportsFloat, decimals: int | None = 2)
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 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 difference(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 split(total: SupportsFloat, weights: List[SupportsFloat], decimals: Optional[int] = 2) -> List[float]:
81+
"""
82+
split 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 display(value: SupportsFloat, decimals: Optional[int] = 2, suffix: str = "%", multiply:bool = False) -> str:
110+
"""
111+
display takes 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+
multiply: bool defaulted to False, if True multiplies the value by 100 turning it to percentage of 100.
119+
120+
121+
Returns:
122+
str: Formatted percentage string, e.g. "25.0%".
123+
"""
124+
v = float(value)
125+
if multiply:
126+
v *= 100
127+
rounded = _round_value(float(v), decimals)
128+
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.3"
8-
description = "A micro-library to easily calculate percentage between two numbers."
7+
version = "0.3.1"
8+
description = "A 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: 106 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, change, difference, split, display
3+
4+
5+
# --- percent ---
26

37
def test_percent_normal():
48
assert percent(50, 200) == 25.0
@@ -8,3 +12,104 @@ 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+
# --- change ---
28+
29+
def test_change_increase():
30+
assert change(100, 150) == 50.0
31+
32+
def test_change_decrease():
33+
assert change(200, 150) == -25.0
34+
35+
def test_change_no_change():
36+
assert change(100, 100) == 0.0
37+
38+
def test_change_zero_old():
39+
assert change(0, 100) == 0.0
40+
41+
def test_change_negative_old():
42+
assert change(-100, -50) == 50.0
43+
44+
def test_change_custom_decimals():
45+
assert change(3, 7, 4) == 133.3333
46+
47+
48+
# --- difference ---
49+
50+
def test_difference_basic():
51+
assert difference(10, 20) == 66.67
52+
53+
def test_difference_same():
54+
assert difference(50, 50) == 0.0
55+
56+
def test_difference_both_zero():
57+
assert difference(0, 0) == 0.0
58+
59+
def test_difference_order_independent():
60+
assert difference(10, 20) == difference(20, 10)
61+
62+
def test_difference_custom_decimals():
63+
assert difference(3, 7, 4) == 80.0
64+
65+
66+
# --- split ---
67+
68+
def test_split_equal():
69+
assert split(100, [1, 1, 1]) == [pytest.approx(33.33), pytest.approx(33.33), pytest.approx(33.33)]
70+
71+
def test_split_weighted():
72+
assert split(200, [1, 3]) == [50.0, 150.0]
73+
74+
def test_split_single():
75+
assert split(100, [5]) == [100.0]
76+
77+
def test_split_empty():
78+
with pytest.raises(ValueError):
79+
split(100, [])
80+
81+
def test_split_zero_weights():
82+
with pytest.raises(ValueError):
83+
split(100, [0, 0])
84+
85+
def test_split_custom_decimals():
86+
result = split(100, [1, 1, 1], 4)
87+
assert result == [pytest.approx(33.3333), pytest.approx(33.3333), pytest.approx(33.3333)]
88+
89+
90+
# --- display ---
91+
92+
def test_display_basic():
93+
assert display(25.0) == "25.0%"
94+
95+
def test_display_custom_decimals():
96+
assert display(33.3333, 1) == "33.3%"
97+
98+
def test_display_custom_suffix():
99+
assert display(50, suffix=" percent") == "50.0 percent"
100+
101+
def test_display_no_rounding():
102+
result = display(33.3333, None)
103+
assert result == "33.3333%"
104+
105+
def test_display_zero():
106+
assert display(0) == "0.0%"
107+
108+
def test_display_multiply():
109+
assert display(0.45, multiply=True) == "45.0%"
110+
111+
def test_display_multiply_small():
112+
assert display(0.0725, multiply=True) == "7.25%"
113+
114+
def test_display_multiply_false():
115+
assert display(0.45) == "0.45%"

0 commit comments

Comments
 (0)