Skip to content

Commit 17cafe5

Browse files
authored
Merge pull request #6 from lancalc/main
Added address and network validation (#6)
2 parents 1ffdf21 + 8a9b2e4 commit 17cafe5

File tree

3 files changed

+106
-21
lines changed

3 files changed

+106
-21
lines changed

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,25 @@ Support IPv4 address formats, subnet masks and prefixes. This tool is particular
1515

1616
### Installation
1717

18-
Install PIP
18+
**Install LanCalc Stable version:**
1919

2020
```bash
21-
curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py
22-
python3 /tmp/get-pip.py
21+
pip3 install lancalc
2322
```
2423

25-
Install LanCalc with one command:
24+
**Or install LanCalc from GitHub:**
2625

2726
```bash
2827
pip3 install git+https://github.com/lancalc/lancalc.git
2928
```
3029

30+
Install PIP
31+
32+
```bash
33+
curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py
34+
python3 /tmp/get-pip.py
35+
```
36+
3137
If the `lancalc` command is not found after installation, add the local packages path to PATH:
3238

3339
```bash

lancalc/main.py

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -221,21 +221,50 @@ def _get_cidr_linux(ip: str) -> int:
221221

222222
def validate_ip(ip_str: str) -> None:
223223
"""Validate IPv4 address format."""
224+
if not ip_str or not ip_str.strip():
225+
raise ValueError("IP address cannot be empty")
226+
227+
ip_str = ip_str.strip()
228+
229+
# Check basic format with regex first for better error messages
230+
ip_pattern = r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$"
231+
match = re.match(ip_pattern, ip_str)
232+
if not match:
233+
raise ValueError(f"Invalid IP address format. Expected format: x.x.x.x (e.g., 192.168.1.1), got: {ip_str}")
234+
235+
# Check each octet range
236+
octets = [int(match.group(i)) for i in range(1, 5)]
237+
for i, octet in enumerate(octets, 1):
238+
if not 0 <= octet <= 255:
239+
raise ValueError(f"Invalid octet {i}: {octet}. Each octet must be between 0 and 255")
240+
241+
# Final validation with ipaddress module
224242
try:
225243
ipaddress.IPv4Address(ip_str)
226244
except ipaddress.AddressValueError as e:
227-
raise ValueError(f"Invalid IP address format: {ip_str}") from e
245+
raise ValueError(f"Invalid IP address: {ip_str}") from e
228246

229247

230248
def validate_prefix(prefix_str: str) -> int:
231249
"""Validate CIDR prefix and return as integer."""
250+
if not prefix_str or not prefix_str.strip():
251+
raise ValueError("Prefix cannot be empty")
252+
253+
prefix_str = prefix_str.strip()
254+
255+
# Check if it's a valid integer
256+
if not prefix_str.isdigit():
257+
raise ValueError(f"Prefix must be a number, got: {prefix_str}")
258+
232259
try:
233260
p = int(prefix_str)
234-
if not 0 <= p <= 32:
235-
raise ValueError(f"CIDR prefix must be 0-32, got {p}")
236-
return p
237-
except ValueError as e:
238-
raise ValueError(f"Invalid CIDR prefix: {prefix_str}") from e
261+
except ValueError:
262+
p = int(prefix_str)
263+
264+
if not 0 <= p <= 32:
265+
raise ValueError(f"Prefix must be between 0 and 32, got: {p}")
266+
267+
return p
239268

240269

241270
def compute(ip: str, prefix: int) -> dict:
@@ -288,6 +317,45 @@ def compute(ip: str, prefix: int) -> dict:
288317
}
289318

290319

320+
def validate_cidr_format(cidr_str: str) -> tuple[str, str]:
321+
"""
322+
Validate CIDR format and provide detailed error messages.
323+
324+
Args:
325+
cidr_str: CIDR notation string
326+
327+
Returns:
328+
Tuple of (ip, prefix_str) if valid
329+
330+
Raises:
331+
ValueError: with specific error message about what's wrong
332+
"""
333+
if not cidr_str or not cidr_str.strip():
334+
raise ValueError("Empty input. Please provide an address in CIDR format (e.g., 192.168.1.1/24)")
335+
336+
cidr_str = cidr_str.strip()
337+
338+
# Check if it contains the slash separator
339+
if '/' not in cidr_str:
340+
raise ValueError(f"Missing '/' separator. Expected format: IP/PREFIX (e.g., 192.168.1.1/24), got: {cidr_str}")
341+
342+
# Split into IP and prefix parts
343+
parts = cidr_str.split('/')
344+
if len(parts) != 2:
345+
raise ValueError(f"Invalid format. Expected exactly one '/' separator, got: {cidr_str}")
346+
347+
ip_part, prefix_part = parts
348+
349+
# Validate IP part format
350+
if not ip_part.strip():
351+
raise ValueError("IP address part is empty. Expected format: IP/PREFIX (e.g., 192.168.1.1/24)")
352+
353+
if not prefix_part.strip():
354+
raise ValueError("Prefix part is empty. Expected format: IP/PREFIX (e.g., 192.168.1.1/24)")
355+
356+
return ip_part.strip(), prefix_part.strip()
357+
358+
291359
def parse_cidr(cidr_str: str) -> tuple[str, int]:
292360
"""
293361
Parse CIDR notation (e.g., "192.168.1.1/24") into IP and prefix.
@@ -301,15 +369,22 @@ def parse_cidr(cidr_str: str) -> tuple[str, int]:
301369
Raises:
302370
ValueError: if CIDR format is invalid
303371
"""
304-
pattern = r"^(?P<ip>\d{1,3}(?:\.\d{1,3}){3})/(?P<prefix>\d{1,2})$"
305-
match = re.match(pattern, cidr_str.strip())
306-
if not match:
307-
raise ValueError("Expected ADDRESS in CIDR form, e.g. 192.168.88.254/24")
372+
# First validate the format and get parts
373+
ip_part, prefix_part = validate_cidr_format(cidr_str)
308374

309-
ip = match.group('ip')
310-
prefix = validate_prefix(match.group('prefix'))
375+
# Validate IP address format
376+
try:
377+
validate_ip(ip_part)
378+
except ValueError as e:
379+
raise ValueError(f"Invalid IP address '{ip_part}': {str(e)}")
311380

312-
return ip, prefix
381+
# Validate prefix
382+
try:
383+
prefix = validate_prefix(prefix_part)
384+
except ValueError as e:
385+
raise ValueError(f"Invalid prefix '{prefix_part}': {str(e)}")
386+
387+
return ip_part, prefix
313388

314389

315390
def compute_from_cidr(cidr_str: str) -> dict:
@@ -692,7 +767,7 @@ def main(argv=None) -> int:
692767

693768
args = parser.parse_args(argv)
694769

695-
have_addr = bool(args.address)
770+
have_addr = bool(args.address and args.address.strip())
696771
headless = is_headless_linux()
697772

698773
# Conditions for CLI: there is an address OR CLI is forced OR headless environment
@@ -708,6 +783,10 @@ def main(argv=None) -> int:
708783
else:
709784
print_result_stdout(res)
710785
return 0
786+
except ValueError as e:
787+
# Log validation errors to stderr only
788+
logger.error(f"Validation error: {str(e)}")
789+
return 1
711790
except Exception as e:
712791
logger.error(f"{type(e).__name__} {str(e)}\n{traceback.format_exc()}")
713792
return 1

test_lancalc.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,13 +440,13 @@ def test_parse_cidr_valid(self):
440440

441441
def test_parse_cidr_invalid_format(self):
442442
"""Test invalid CIDR format."""
443-
with pytest.raises(ValueError, match="Expected ADDRESS in CIDR form"):
443+
with pytest.raises(ValueError, match="Missing '/' separator"):
444444
parse_cidr("192.168.1.1")
445445

446-
with pytest.raises(ValueError, match="Expected ADDRESS in CIDR form"):
446+
with pytest.raises(ValueError, match="Prefix part is empty"):
447447
parse_cidr("192.168.1.1/")
448448

449-
with pytest.raises(ValueError, match="Expected ADDRESS in CIDR form"):
449+
with pytest.raises(ValueError, match="IP address part is empty"):
450450
parse_cidr("/24")
451451

452452
def test_compute_from_cidr(self):

0 commit comments

Comments
 (0)