bharatutils 0.1.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,9 @@
1
+ """bharatutils — Python utilities for Indian developers."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .number_format import to_lakh, to_crore, format_inr
6
+ from .gst import validate_gstin, validate_gstin_strict, parse_gstin
7
+ from .pan import validate_pan, parse_pan
8
+ from .address import parse_address, extract_pincode, pincode_to_state
9
+ from .festivals import get_festivals, next_festival, days_until, is_festival
bharatutils/address.py ADDED
@@ -0,0 +1,78 @@
1
+
2
+ import re
3
+
4
+ PINCODE_PATTERN = re.compile(r"\b[1-9][0-9]{5}\b")
5
+
6
+ PINCODE_PREFIX_TO_STATE = {
7
+ "11": "Delhi",
8
+ "12": "Haryana", "13": "Haryana",
9
+ "14": "Punjab", "15": "Punjab",
10
+ "16": "Chandigarh",
11
+ "17": "Himachal Pradesh",
12
+ "18": "Jammu & Kashmir", "19": "Jammu & Kashmir",
13
+ "20": "Uttar Pradesh", "21": "Uttar Pradesh", "22": "Uttar Pradesh",
14
+ "23": "Uttar Pradesh", "24": "Uttar Pradesh", "25": "Uttar Pradesh",
15
+ "26": "Uttar Pradesh", "27": "Uttar Pradesh", "28": "Uttar Pradesh",
16
+ "30": "Rajasthan", "31": "Rajasthan", "32": "Rajasthan", "33": "Rajasthan", "34": "Rajasthan",
17
+ "36": "Gujarat", "37": "Gujarat", "38": "Gujarat", "39": "Gujarat",
18
+ "40": "Maharashtra", "41": "Maharashtra", "42": "Maharashtra",
19
+ "43": "Maharashtra", "44": "Maharashtra",
20
+ "45": "Madhya Pradesh", "46": "Madhya Pradesh", "47": "Madhya Pradesh", "48": "Madhya Pradesh",
21
+ "49": "Chhattisgarh",
22
+ "50": "Telangana",
23
+ "51": "Andhra Pradesh", "52": "Andhra Pradesh", "53": "Andhra Pradesh",
24
+ "56": "Karnataka", "57": "Karnataka", "58": "Karnataka", "59": "Karnataka",
25
+ "60": "Tamil Nadu", "61": "Tamil Nadu", "62": "Tamil Nadu", "63": "Tamil Nadu", "64": "Tamil Nadu",
26
+ "67": "Kerala", "68": "Kerala", "69": "Kerala",
27
+ "70": "West Bengal", "71": "West Bengal", "72": "West Bengal", "73": "West Bengal", "74": "West Bengal",
28
+ "75": "Odisha", "76": "Odisha", "77": "Odisha",
29
+ "78": "Assam",
30
+ "79": "North East",
31
+ "80": "Bihar", "81": "Bihar", "82": "Bihar", "83": "Bihar", "84": "Bihar", "85": "Bihar",
32
+ "90": "Army Postal Service", "91": "Army Postal Service", "92": "Army Postal Service",
33
+ "99": "Army Postal Service",
34
+ }
35
+
36
+ KNOWN_CITIES = [
37
+ "Mumbai", "Delhi", "Bangalore", "Bengaluru", "Hyderabad", "Chennai",
38
+ "Kolkata", "Pune", "Ahmedabad", "Jaipur", "Surat", "Lucknow",
39
+ "Kanpur", "Nagpur", "Indore", "Bhopal", "Patna", "Vadodara",
40
+ "Ludhiana", "Agra", "Nashik", "Varanasi", "Amritsar", "Noida",
41
+ "Gurgaon", "Gurugram", "Chandigarh", "Coimbatore", "Kochi", "Goa",
42
+ "Visakhapatnam", "Thane", "Guwahati", "Bhubaneswar", "Mysore", "Mysuru",
43
+ ]
44
+
45
+
46
+ def extract_pincode(address):
47
+ """Find the pincode in a messy address string. None if not found."""
48
+ if address is None or not isinstance(address, str):
49
+ return None
50
+ cleaned = re.sub(r"(\b[1-9][0-9]{2})\s+([0-9]{3}\b)", r"\1\2", address)
51
+ match = PINCODE_PATTERN.search(cleaned)
52
+ return match.group() if match else None
53
+
54
+
55
+ def pincode_to_state(pincode):
56
+ """Get the state from a pincode. None if unknown."""
57
+ if pincode is None:
58
+ return None
59
+ pincode = str(pincode).strip()
60
+ if not re.match(r"^[1-9][0-9]{5}$", pincode):
61
+ return None
62
+ return PINCODE_PREFIX_TO_STATE.get(pincode[:2])
63
+
64
+
65
+ def parse_address(address):
66
+ """Parse a messy Indian address. Always returns a dict."""
67
+ result = {"raw": address, "pincode": None, "state": None, "city": None}
68
+ if address is None or not isinstance(address, str):
69
+ return result
70
+ result["pincode"] = extract_pincode(address)
71
+ if result["pincode"]:
72
+ result["state"] = pincode_to_state(result["pincode"])
73
+ address_lower = address.lower()
74
+ for city in KNOWN_CITIES:
75
+ if re.search(r"\b" + city.lower() + r"\b", address_lower):
76
+ result["city"] = city
77
+ break
78
+ return result
@@ -0,0 +1,74 @@
1
+
2
+ from datetime import date
3
+
4
+ FIXED_FESTIVALS = {
5
+ "New Year": (1, 1),
6
+ "Republic Day": (1, 26),
7
+ "Independence Day": (8, 15),
8
+ "Gandhi Jayanti": (10, 2),
9
+ "Christmas": (12, 25),
10
+ }
11
+
12
+ # Verified against official Government of India holiday lists
13
+ MOVABLE_FESTIVALS = {
14
+ 2026: {
15
+ "Makar Sankranti": (1, 14),
16
+ "Holi": (3, 4),
17
+ "Eid ul-Fitr": (3, 21),
18
+ "Ram Navami": (3, 26),
19
+ "Mahavir Jayanti": (3, 31),
20
+ "Good Friday": (4, 3),
21
+ "Eid ul-Adha": (5, 27),
22
+ "Muharram": (6, 26),
23
+ "Milad-un-Nabi": (8, 26),
24
+ "Janmashtami": (9, 4),
25
+ "Ganesh Chaturthi": (9, 14),
26
+ "Dussehra": (10, 20),
27
+ "Diwali": (11, 8),
28
+ "Guru Nanak Jayanti": (11, 24),
29
+ },
30
+ }
31
+
32
+
33
+ def get_festivals(year):
34
+ """All festivals for a year, sorted by date."""
35
+ festivals = []
36
+ for name, (m, d) in FIXED_FESTIVALS.items():
37
+ festivals.append({"name": name, "date": date(year, m, d), "type": "fixed"})
38
+ if year in MOVABLE_FESTIVALS:
39
+ for name, (m, d) in MOVABLE_FESTIVALS[year].items():
40
+ festivals.append({"name": name, "date": date(year, m, d), "type": "movable"})
41
+ festivals.sort(key=lambda f: f["date"])
42
+ return festivals
43
+
44
+
45
+ def next_festival(from_date=None):
46
+ """The next upcoming festival from a given date (default: today)."""
47
+ if from_date is None:
48
+ from_date = date.today()
49
+ for year in [from_date.year, from_date.year + 1]:
50
+ for f in get_festivals(year):
51
+ if f["date"] >= from_date:
52
+ return f
53
+ return None
54
+
55
+
56
+ def days_until(festival_name, from_date=None):
57
+ """Days remaining until a festival. None if not found."""
58
+ if from_date is None:
59
+ from_date = date.today()
60
+ for year in [from_date.year, from_date.year + 1]:
61
+ for f in get_festivals(year):
62
+ if f["name"].lower() == festival_name.lower() and f["date"] >= from_date:
63
+ return (f["date"] - from_date).days
64
+ return None
65
+
66
+
67
+ def is_festival(check_date=None):
68
+ """Is this date a festival? Returns the festival name or None."""
69
+ if check_date is None:
70
+ check_date = date.today()
71
+ for f in get_festivals(check_date.year):
72
+ if f["date"] == check_date:
73
+ return f["name"]
74
+ return None
bharatutils/gst.py ADDED
@@ -0,0 +1,71 @@
1
+
2
+ import re
3
+
4
+ STATE_CODES = {
5
+ "01": "Jammu & Kashmir", "02": "Himachal Pradesh",
6
+ "03": "Punjab", "04": "Chandigarh",
7
+ "05": "Uttarakhand", "06": "Haryana",
8
+ "07": "Delhi", "08": "Rajasthan",
9
+ "09": "Uttar Pradesh", "10": "Bihar",
10
+ "11": "Sikkim", "12": "Arunachal Pradesh",
11
+ "13": "Nagaland", "14": "Manipur",
12
+ "15": "Mizoram", "16": "Tripura",
13
+ "17": "Meghalaya", "18": "Assam",
14
+ "19": "West Bengal", "20": "Jharkhand",
15
+ "21": "Odisha", "22": "Chhattisgarh",
16
+ "23": "Madhya Pradesh", "24": "Gujarat",
17
+ "25": "Daman & Diu", "26": "Dadra & Nagar Haveli",
18
+ "27": "Maharashtra", "28": "Andhra Pradesh",
19
+ "29": "Karnataka", "30": "Goa",
20
+ "31": "Lakshadweep", "32": "Kerala",
21
+ "33": "Tamil Nadu", "34": "Puducherry",
22
+ "35": "Andaman & Nicobar","36": "Telangana",
23
+ "37": "Andhra Pradesh (New)",
24
+ }
25
+
26
+ GSTIN_PATTERN = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$")
27
+
28
+
29
+ def validate_gstin(gstin):
30
+ """Check if a GSTIN has valid format and a real state code."""
31
+ if gstin is None or not isinstance(gstin, str):
32
+ return False
33
+ gstin = gstin.strip().upper()
34
+ if len(gstin) != 15 or not GSTIN_PATTERN.match(gstin):
35
+ return False
36
+ return gstin[:2] in STATE_CODES
37
+
38
+
39
+ def parse_gstin(gstin):
40
+ """Break a GSTIN into state, PAN, entity number. None if invalid."""
41
+ if not validate_gstin(gstin):
42
+ return None
43
+ gstin = gstin.strip().upper()
44
+ return {
45
+ "gstin": gstin,
46
+ "state_code": gstin[:2],
47
+ "state": STATE_CODES[gstin[:2]],
48
+ "pan": gstin[2:12],
49
+ "entity_number": gstin[12],
50
+ "check_digit": gstin[14],
51
+ }
52
+
53
+
54
+ def gstin_check_digit(gstin_first_14):
55
+ """Official mod-36 checksum for GSTIN."""
56
+ chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
57
+ total = 0
58
+ for i, char in enumerate(gstin_first_14):
59
+ value = chars.index(char)
60
+ factor = 2 if i % 2 != 0 else 1
61
+ product = value * factor
62
+ total += product // 36 + product % 36
63
+ return chars[(36 - total % 36) % 36]
64
+
65
+
66
+ def validate_gstin_strict(gstin):
67
+ """Full validation: format + state + checksum."""
68
+ if not validate_gstin(gstin):
69
+ return False
70
+ gstin = gstin.strip().upper()
71
+ return gstin_check_digit(gstin[:14]) == gstin[14]
@@ -0,0 +1,38 @@
1
+
2
+ def to_lakh(number):
3
+ """Convert a number to lakhs. Handles strings, negatives, None, NaN."""
4
+ if number is None or (isinstance(number, float) and number != number):
5
+ return None
6
+ if isinstance(number, str):
7
+ number = float(number.replace(",", "").replace("₹", "").strip())
8
+ return round(number / 100_000, 2)
9
+
10
+
11
+ def to_crore(number):
12
+ """Convert a number to crores. Handles strings, negatives, None, NaN."""
13
+ if number is None or (isinstance(number, float) and number != number):
14
+ return None
15
+ if isinstance(number, str):
16
+ number = float(number.replace(",", "").replace("₹", "").strip())
17
+ return round(number / 10_000_000, 2)
18
+
19
+
20
+ def format_inr(number, symbol=True):
21
+ """
22
+ Format a number in Indian style (lakh/crore).
23
+ symbol=False gives you 15.0 L without the rupee sign.
24
+ """
25
+ if number is None or (isinstance(number, float) and number != number):
26
+ return "N/A"
27
+ if isinstance(number, str):
28
+ number = float(number.replace(",", "").replace("₹", "").strip())
29
+ prefix = "₹" if symbol else ""
30
+ negative = number < 0
31
+ number = abs(number)
32
+ if number >= 10_000_000:
33
+ result = f"{prefix}{to_crore(number)} Cr"
34
+ elif number >= 100_000:
35
+ result = f"{prefix}{to_lakh(number)} L"
36
+ else:
37
+ result = f"{prefix}{number:,.0f}"
38
+ return f"-{result}" if negative else result
bharatutils/pan.py ADDED
@@ -0,0 +1,40 @@
1
+
2
+ import re
3
+
4
+ PAN_PATTERN = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]{1}$")
5
+
6
+ HOLDER_TYPES = {
7
+ "P": "Individual",
8
+ "C": "Company",
9
+ "H": "Hindu Undivided Family",
10
+ "F": "Firm / Partnership",
11
+ "T": "Trust",
12
+ "G": "Government",
13
+ "A": "Association of Persons",
14
+ "B": "Body of Individuals",
15
+ "L": "Local Authority",
16
+ "J": "Artificial Juridical Person",
17
+ }
18
+
19
+
20
+ def validate_pan(pan):
21
+ """Check if a PAN is valid. Returns True or False."""
22
+ if pan is None or not isinstance(pan, str):
23
+ return False
24
+ pan = pan.strip().upper()
25
+ if len(pan) != 10 or not PAN_PATTERN.match(pan):
26
+ return False
27
+ return pan[3] in HOLDER_TYPES
28
+
29
+
30
+ def parse_pan(pan):
31
+ """Extract holder type and name initial from a PAN."""
32
+ if not validate_pan(pan):
33
+ return None
34
+ pan = pan.strip().upper()
35
+ return {
36
+ "pan": pan,
37
+ "holder_type": HOLDER_TYPES[pan[3]],
38
+ "name_initial": pan[4],
39
+ "is_individual": pan[3] == "P",
40
+ }
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: bharatutils
3
+ Version: 0.1.0
4
+ Summary: Python utilities for Indian developers — lakh/crore formatting, GST & PAN validation, address parsing, festival calendar
5
+ Author: Ansuman Jaiswal
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/iam-ansuman/bharatutils
8
+ Project-URL: Issues, https://github.com/iam-ansuman/bharatutils/issues
9
+ Keywords: india,gst,pan,lakh,crore,inr,pincode,address,festivals,indian
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # bharatutils 🇮🇳
26
+
27
+ Python utilities for Indian developers — because `1,500,000` should display as `₹15 L`.
28
+
29
+ Lakh/crore formatting · GST & PAN validation · Address parsing · Festival calendar
30
+
31
+ ## Why?
32
+
33
+ Every Indian developer has written these same utility functions a hundred times:
34
+
35
+ ```python
36
+ # Without bharatutils 😩
37
+ df["salary_display"] = df["salary"].apply(
38
+ lambda x: f"₹{round(x/100000, 2)} L" if x >= 100000 else f"₹{x:,}"
39
+ ) # ...and it crashes on NaN
40
+
41
+ # With bharatutils 😎
42
+ from bharatutils import format_inr
43
+ df["salary_display"] = df["salary"].apply(format_inr) # handles NaN, strings, negatives
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install bharatutils
50
+ ```
51
+
52
+ ## Features
53
+
54
+ ### 💰 Indian number formatting
55
+ ```python
56
+ from bharatutils import format_inr, to_lakh, to_crore
57
+
58
+ format_inr(1500000) # '₹15.0 L'
59
+ format_inr(50000000) # '₹5.0 Cr'
60
+ format_inr("15,00,000") # '₹15.0 L' — handles messy strings
61
+ to_lakh(1500000) # 15.0
62
+ ```
63
+
64
+ ### 🧾 GST validation — with real checksum
65
+ ```python
66
+ from bharatutils import validate_gstin_strict, parse_gstin
67
+
68
+ validate_gstin_strict("27AAAPZ2318J1ZI") # True — verifies the check digit
69
+ parse_gstin("27AAAPZ2318J1ZI")
70
+ # {'state': 'Maharashtra', 'pan': 'AAAPZ2318J', 'entity_number': '1', ...}
71
+ ```
72
+ Catches single-character typos that format-only validators miss.
73
+
74
+ ### 🪪 PAN validation + holder type
75
+ ```python
76
+ from bharatutils import parse_pan
77
+
78
+ parse_pan("AAAPZ2318J")
79
+ # {'holder_type': 'Individual', 'is_individual': True, ...}
80
+ # P=Person, C=Company, T=Trust, G=Government...
81
+ ```
82
+
83
+ ### 📍 Indian address parsing
84
+ ```python
85
+ from bharatutils import parse_address
86
+
87
+ parse_address("Flat 302, Nr. SBI ATM, MG Road, Pune - 411001")
88
+ # {'pincode': '411001', 'state': 'Maharashtra', 'city': 'Pune'}
89
+ ```
90
+ Handles space-broken pincodes ("700 016"), ignores phone numbers, never crashes.
91
+
92
+ ### 🪔 Festival calendar
93
+ ```python
94
+ from bharatutils import next_festival, days_until
95
+
96
+ next_festival() # {'name': 'Muharram', 'date': datetime.date(2026, 6, 26)}
97
+ days_until("Diwali") # 150
98
+ ```
99
+ Covers Hindu, Muslim, Christian, Sikh & Jain festivals plus national holidays — verified against official Government of India lists.
100
+
101
+ ## Status
102
+
103
+ `v0.1.0` — early but tested. Pincode→state mapping is prefix-based (~95% accurate); exact-lookup coming in v0.2.
104
+
105
+ Found a bug? [Open an issue](../../issues) — responses are fast.
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,11 @@
1
+ bharatutils/__init__.py,sha256=mueOFE44C_eYSamdm9auXuIhPP7JieSwAVBDsMxkCyA,417
2
+ bharatutils/address.py,sha256=uhqo-kGQQT3HW-39YufUy5jvqxfMrBt0AXMACk-ToRk,3343
3
+ bharatutils/festivals.py,sha256=uZXOo2VmoMaSL3MPTv85DBqGX1om19Qt2_jjpLVxNIY,2402
4
+ bharatutils/gst.py,sha256=HS0IvX8YG3nuG1w_8EfyH0tDfEiAMXNG5NoXTcEqKHg,2393
5
+ bharatutils/number_format.py,sha256=o18vAJbLdbTuwbOyALrXtGSYIbSlq1f52Rbb8xRgfM0,1391
6
+ bharatutils/pan.py,sha256=6EJVXAqwOhlGrrf3QWtG9Zl2fmT7hsdQNZ57YauwKoI,980
7
+ bharatutils-0.1.0.dist-info/licenses/LICENSE,sha256=0FgLW7i4VQLgVuuM9SILS92HZCJzTqS6hQG79eHqIzM,1072
8
+ bharatutils-0.1.0.dist-info/METADATA,sha256=Bp9OX64XeFRpuDy_DSTLMo6ODMqXxCtrP8bpahRnEVI,3463
9
+ bharatutils-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ bharatutils-0.1.0.dist-info/top_level.txt,sha256=B-7-oVIICxabiTjrEfjn8aQyzBUkzHkTQhW1nQMv2LY,12
11
+ bharatutils-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ansuman Jaiswal
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
+ bharatutils