From b886736437cc2e470d0aeb78b4cc04ac4a154fc3 Mon Sep 17 00:00:00 2001 From: tes3awy Date: Sun, 25 Jul 2021 01:00:46 +0200 Subject: [PATCH 1/5] Update hooks --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5bc272d..432522e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,6 @@ repos: args: ["--profile", "black"] name: isort (python) - repo: https://github.com/asottile/pyupgrade - rev: v2.21.2 + rev: v2.22.0 hooks: - id: pyupgrade From f7eee192c07183b8af0c0017be4038e360a99ce0 Mon Sep 17 00:00:00 2001 From: tes3awy Date: Sun, 25 Jul 2021 01:01:41 +0200 Subject: [PATCH 2/5] Refactor code --- export_subnets.py | 50 +++++++++++----------- main.py | 53 +++++++++-------------- parse_excel.py | 16 ++----- read_subnets.py | 28 ++++++------- subnetting.py | 104 +++++++++++++++++++--------------------------- svi_generator.py | 40 ++++++++++-------- 6 files changed, 125 insertions(+), 166 deletions(-) diff --git a/export_subnets.py b/export_subnets.py index 9269101..9328297 100644 --- a/export_subnets.py +++ b/export_subnets.py @@ -1,7 +1,7 @@ #!usr/bin/env python3 from datetime import date -from typing import AnyStr, Dict, List +from typing import AnyStr, Dict, List, Optional from termcolor import colored, cprint from xlsxwriter import Workbook @@ -9,26 +9,30 @@ def export_subnets( subnets: List[Dict], - workbook_name: AnyStr = "IP-Schema", - worksheet_name: AnyStr = "IP Schema Worksheet", + workbook_name: Optional[AnyStr] = "New-Schema.xlsx", ): - """Export an Excel file of entered subnets - - Args: - subnets (List[Dict]): Processed subnets - workbook_name (AnyStr, optional): Name of the Excel file. Defaults to "IP-Schema". - worksheet_name (AnyStr, optional): Name of the sheet within the Excel file. Defaults to "IP Schema Worksheet". - - Raises: - SystemExit: TypeError + """Exports an Excel file of subnetting data + + Parameters + ---------- + subnets : List[Dict] + List of subnets went througth subnetting + workbook_name : Optional[AnyStr], optional + Name of Workbook to create, by default "New-Schema.xlsx" + + Raises + ------ + SystemExit + TypeError, KeyError """ - excel_fname = f"{workbook_name}_{date.today()}.xlsx" + wb_name, ext = workbook_name.split(".") + excel_fname = f"{wb_name}_{date.today()}.{ext}" # Create an Excel file with Workbook(filename=excel_fname) as workbook: # Create a sheet within the Excel file - worksheet = workbook.add_worksheet(name=worksheet_name) + worksheet = workbook.add_worksheet(name="Subnetting Results") # Filters worksheet.autofilter("A1:L1") # Freeze top row and 2 most left columns @@ -51,7 +55,7 @@ def export_subnets( } # Header line format - header_line_frmt = workbook.add_format( + h_frmt = workbook.add_format( properties={ "bold": True, "border": True, @@ -62,15 +66,11 @@ def export_subnets( # Create a header line row for cell, value in header_line.items(): - worksheet.write_string(cell, value, cell_format=header_line_frmt) + worksheet.write_string(cell, value, cell_format=h_frmt) # Generic cell format c_frmt = workbook.add_format( - properties={ - "border": True, - "align": "center", - "valign": "vcenter", - } + properties={"border": True, "align": "center", "valign": "vcenter"} ) # Format cell containing number @@ -106,8 +106,6 @@ def export_subnets( # Jump to next row row += 1 - except TypeError as e: - raise SystemExit(colored(f"export_subnets.py: {e}", "red")) - except KeyError as e: - raise SystemExit(colored(f"export_subnets.py: {e}", "red")) - cprint(f"\nPlease check {excel_fname} in the PWD.\n", "green") + except (TypeError, KeyError) as e: + raise SystemExit(colored(text=f"export_subnets.py: {e}", color="red")) + cprint(text=f"\nPlease check {excel_fname} in the PWD.\n", color="green") diff --git a/main.py b/main.py index 8a2f519..fa7e73b 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,10 @@ #!usr/bin/env python3 +import time from getpass import getuser from colorama import init -from termcolor import colored +from termcolor import colored, cprint from export_subnets import export_subnets from read_subnets import read_subnets @@ -15,59 +16,43 @@ def main(): try: # CSV file - input_subnets = ( - input(f"\n- CSV file w/ extension? [Defaults to subnets.csv]: ") - or "subnets.csv" - ) - if ".csv" not in input_subnets: - raise SystemExit( - colored("Sorry! The input file MUST include .csv extension", "red") - ) + input_csv = input(f"\n- CSV file [subnets.csv]: ") or "subnets.csv" gateway = int( - input("- The gateway, first or last IP Address? [0/1] [Defaults to 0]: ") - or "0" + input("- The gateway, first or last IP Address [0/1] [0]: ") or "0" ) if gateway not in (0, 1): raise SystemExit( - colored( - "0 and 1 are the only allowed values! 0: First IP, 1: Last IP", - "red", - ) + colored(text="0 and 1 are the only allowed values!", color="red") ) # Excel file name workbook_name = ( - input("- Excel file w/o extension? [Defaults to IP-Schema]: ") - or "IP-Schema" - ) - if workbook_name.endswith(".xlsx"): - raise SystemExit(colored("Oops! Please remove the .xlsx extension", "red")) - # Excel sheet name - worksheet_name = ( - input("- Worksheet name? [Defaults to IP Schema Worksheet]: ") - or "IP Schema Worksheet" + input("- Excel file to create [New-Schema.xlsx]: ") or "New-Schema.xlsx" ) + start = time.perf_counter() + # Read CSV file - subnets = read_subnets(file_path=input_subnets) + subnets = read_subnets(file_path=input_csv) # Do Subnetting network_subnets = subnetting(input_subnets=subnets, gateway=gateway) # Export subnetting results to an Excel file - export_subnets( - subnets=network_subnets, - workbook_name=workbook_name, - worksheet_name=worksheet_name[:31], - ) + export_subnets(subnets=network_subnets, workbook_name=workbook_name) + + end = time.perf_counter() + + delta = round(end - start, 2) + cprint(text=f"Finished in {delta} second(s)", on_color="on_blue") except FileNotFoundError: raise SystemExit( - colored(f"main.py: {input_subnets} file does not exist!", "red") + colored(text=f"`{input_csv}` file does not exist!", color="red") ) except KeyboardInterrupt: - raise SystemExit(colored(f"\nProcess interrupted by {getuser()}", "yellow")) - - print("Done") + raise SystemExit( + colored(text=f"Process interrupted by {getuser()}", color="yellow") + ) if __name__ == "__main__": diff --git a/parse_excel.py b/parse_excel.py index ecd4c2d..1bbed04 100644 --- a/parse_excel.py +++ b/parse_excel.py @@ -1,6 +1,5 @@ #!usr/bin/env python3 -import os from argparse import ArgumentParser from colorama import init @@ -32,14 +31,7 @@ args = parser.parse_args() -if not args.file.endswith(".xlsx"): - raise SystemExit(colored("\nInvalid input file. The file MUST be a .xlsx", "red")) -if not os.path.isfile(args.file): - raise SystemExit(colored(f"{args.file} does not exist!", "red")) - -svi_generator(args.file) # Execute the svi_generator - -cprint( - f'\nCreated {args.file.replace(".xlsx", "")}-svi-template.txt successfully.', - "green", -) +try: + svi_generator(excel_file=args.file) +except (FileNotFoundError, PermissionError) as e: + raise SystemExit(colored(text=e, color="red")) diff --git a/read_subnets.py b/read_subnets.py index 15c8612..a1bece1 100644 --- a/read_subnets.py +++ b/read_subnets.py @@ -1,29 +1,25 @@ #!usr/bin/env python3 import csv -from typing import AnyStr, List +from typing import AnyStr, Dict, List -def read_subnets(file_path: AnyStr = "subnets.csv") -> List[List]: +def read_subnets(file_path: AnyStr = "subnets.csv") -> List[Dict[AnyStr, AnyStr]]: """Reads CSV subnets file - Args: - file_path (AnyStr, optional): Path to subnets CSV file. Defaults to "subnets.csv". + Parameters + ---------- + file_path : AnyStr, optional + Name of a CSV file, by default "subnets.csv" - Returns: - List[List]: Subnets in CIDR notation representation + Returns + ------- + List[Dict[AnyStr, AnyStr]] + Subnets in CIDR Notation """ - # Define an empty list to hold all subnets - subnets = [] - # Read subnets CSV file with open(file=file_path, mode="r") as csvfile: next(csvfile) # Skip header line - csv_data = csv.reader( - csvfile, delimiter="\n", dialect="excel", doublequote=True - ) - for subnet in csv_data: - subnets.append(subnet[0]) - - return subnets + csv_data = csv.DictReader(f=csvfile, fieldnames={"cidr"}) + return [cidr for cidr in csv_data] diff --git a/subnetting.py b/subnetting.py index 009ce25..db052fd 100644 --- a/subnetting.py +++ b/subnetting.py @@ -2,89 +2,71 @@ import ipaddress from ipaddress import AddressValueError, NetmaskValueError -from typing import Dict, List +from typing import Any, AnyStr, Dict, List from termcolor import colored -def subnetting(input_subnets: List[List], gateway: int) -> List[Dict]: - """Does subnetting on each value entered by the user +def subnetting(input_subnets: List[Dict], gateway: int) -> List[Dict[AnyStr, Any]]: + """Does subnetting CIDR Notation - Args: - input_subnets (List[List]): Subnets from CSV file - gateway (int): An interger that decides which IP address is the gateway + Parameters + ---------- + input_subnets : List[Dict] + Input subnets in CSV file + gateway : int + The selected Gateway - Raises: - SystemExit: AddressValueError - SystemExit: NetmaskValueError - SystemExit: TypeError - SystemExit: ValueError - SystemExit: IndexError + Returns + ------- + List[Dict[AnyStr, Any]] + Result of subnetting - Returns: - List[Dict]: Networks details + Raises + ------ + SystemExit + AddressValueError, NetmaskValueError, ValueError, TypeError, IndexError """ - try: + # Empty list to hold all subnetting values results = [] # Loop over input_subnets for subnet in input_subnets: - cidr_notation = ipaddress.IPv4Network(subnet) + cidr = ipaddress.IPv4Network(subnet["cidr"]) # Find range of IP addresses - hosts = list(cidr_notation.hosts()) - start_ip = hosts[0] - end_ip = hosts[-1] + hosts = list(cidr.hosts()) + start_ip, end_ip = hosts[0], hosts[-1] # Evaluate wildcard mask from netmask - subnet_mask = str(cidr_notation.netmask).split(".") - wildcard_mask = [] - for octet in subnet_mask: - octet_value = 255 - int(octet) - wildcard_mask.append(octet_value) - + subnet_mask = str(cidr.netmask).split(".") + wildcard_mask = [255 - int(octet) for octet in subnet_mask] wildcard_mask = ".".join(map(str, wildcard_mask)) # Output - # Define an empty dictionary to hold all values - network_details = {} - - network_details["cidr"] = str(cidr_notation) - network_details["net_addr"] = str(cidr_notation.network_address) - network_details["prefix_len"] = str(cidr_notation.prefixlen) - network_details["broadcast_addr"] = str(cidr_notation.broadcast_address) - network_details["netmask"] = str(cidr_notation.netmask) - network_details["wildcard"] = wildcard_mask - network_details["num_hosts"] = len(hosts) - - if start_ip == end_ip: - network_details["range"] = str(start_ip) - else: - network_details["range"] = f"{start_ip} → {end_ip}" - - if gateway: # if gateway == 1: - network_details["gateway"] = str(end_ip) - else: - network_details["gateway"] = str(start_ip) + network_details = { + "cidr": str(cidr), + "net_addr": str(cidr.network_address), + "prefix_len": str(cidr.prefixlen), + "broadcast_addr": str(cidr.broadcast_address), + "netmask": str(cidr.netmask), + "wildcard": wildcard_mask, + "num_hosts": len(hosts), + "range": str(start_ip) + if start_ip == end_ip + else f"{start_ip} → {end_ip}", + "gateway": str(end_ip) if gateway else str(start_ip), + } results.append(network_details) return results - except AddressValueError as e: - raise SystemExit(colored(f"subnetting.py: {e}", "red")) - except NetmaskValueError as e: - raise SystemExit( - colored(f"subnetting.py: {e}. Please check {subnet} prefix length!", "red") - ) - except TypeError as e: + except ( + AddressValueError, + NetmaskValueError, + ValueError, + TypeError, + IndexError, + ) as e: raise SystemExit(colored(f"subnetting.py: {e}", "red")) - except ValueError as e: - raise SystemExit(colored(f"subnetting.py: {e}.", "red")) - except IndexError as e: - raise SystemExit( - colored( - f"subnetting.py:{e}. The input CSV file MUST contain at least one sunbet.", - "red", - ) - ) diff --git a/svi_generator.py b/svi_generator.py index 11912fe..a009245 100644 --- a/svi_generator.py +++ b/svi_generator.py @@ -6,14 +6,16 @@ import pandas as pd from jinja2 import Environment, FileSystemLoader -from numpy import nan +from termcolor import cprint def svi_generator(excel_file: AnyStr) -> None: """Generates an SVI configuration template - Args: - excel_file (AnyStr): Path to the Excel file + Parameters + ---------- + excel_file : AnyStr + Name of an Excel file """ # Handle Jinja template @@ -29,23 +31,27 @@ def svi_generator(excel_file: AnyStr) -> None: data = pd.read_excel( io=os.path.join("./", excel_file), sheet_name=0, usecols="A:B,H:J" ) - df = pd.DataFrame(data=data) - - # Create a vlans List[Dict] from columns - vlans = df.rename( - columns={ - "VLAN ID": "id", - "VLAN Name": "name", - "Gateway": "ipaddr", - "Subnet Mask": "mask", - "IP Helper Address": "helper_addr", - } - ).to_dict(orient="records") + + vlans = ( + pd.DataFrame(data=data) + .rename( + columns={ + "VLAN ID": "id", + "VLAN Name": "name", + "Gateway": "ip", + "Subnet Mask": "mask", + "IP Helper Address": "helper_addr", + } + ) + .to_dict(orient="records") + ) # Render the templpate svi_cfg = template.render(vlans=vlans) # Export the template result to a text file - cfg_fname = f'{excel_file.replace(".xlsx", "")}_svi_template.txt' + cfg_fname = f'{excel_file.replace(".xlsx", "")}_svi.txt' with open(file=cfg_fname, mode="w", encoding="utf-8") as cfg_file: - cfg_file.write(svi_cfg.lstrip()) + cfg_file.write(svi_cfg) + + cprint(text=f"\nCreated {cfg_fname} successfully.", color="green") From 7e585a95662a80f2f1a5ead2d3cdae7b2fb16563 Mon Sep 17 00:00:00 2001 From: tes3awy Date: Sun, 25 Jul 2021 01:02:15 +0200 Subject: [PATCH 3/5] Update Jinja template --- svi.j2 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/svi.j2 b/svi.j2 index b63da9c..96152c1 100644 --- a/svi.j2 +++ b/svi.j2 @@ -4,13 +4,13 @@ configure terminal ! {% for vlan in vlans %} vlan {{ vlan.id }} - name {{ vlan.name|replace(" ","_")|upper }} + name {{ vlan.name|replace(" ","_")|upper|truncate(32, False, "") }} exit ! {% endfor %} {% for vlan in vlans %} interface vlan {{ vlan.id }} - ip address {{ vlan.ipaddr }} {{ vlan.mask }} + ip address {{ vlan.ip }} {{ vlan.mask }} description *** {{ vlan.name|upper }} *** {% if vlan.helper_addr|string != "nan" %} ip helper-address {{ vlan.helper_addr }} From 67c36727cf266be6bc4f31cd7708d8f80c0518a6 Mon Sep 17 00:00:00 2001 From: tes3awy Date: Sun, 25 Jul 2021 01:02:35 +0200 Subject: [PATCH 4/5] Update README --- README.md | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index be3e8c2..122b1b6 100644 --- a/README.md +++ b/README.md @@ -76,40 +76,35 @@ $ pip install -r requirements.txt --user path_to\subnetting> python main.py ``` -**macOS or Linux** +**macOS or Unix** ```bash $ python3 main.py ``` -You will be prompted to enter the name of the CSV file containing input subnets, the gateway IP address, a name for the Excel file to be created, and the name of the sheet within the Excel file. _(All inputs have default values)_. +You will be prompted to enter the name of the CSV file containing input subnets, the gateway, a name for the Excel file to be created. _(All inputs have default values)_. > A `subnets.csv` file can be found in the repo. This file is an entry point to get started using this program. It's prepopulated with three different subnets. _(Class A, B, and C)_. ```bash -- CSV file w/ extension? [Defaults to subnets.csv]: -- The gateway, first or last IP Address? [0/1] [Defaults to 0]: -- Excel file w/o extension? [Defaults to IP-Schema]: Test-Schema -- Worksheet name? [Defaults to IP Schema Worksheet]: Test Worksheet +- CSV file [subnets.csv]: +- The gateway, first or last IP Address [0/1] [0]: +- Excel file to create [New-Schema.xlsx]: ``` -> - Abbreviations:
- **w/: With**
- **w/o: Without** - Voila :sparkles: You have an Excel file that includes all required data about each subnet. ```bash -Please check Test-Schema_.xlsx in current working directory. +Please check New-Schema_.xlsx in current working directory. ``` > **Default behaviors:** -> 1. CIDR notation with no prefix length will be handled as /32.
- For example, if you enter `10.0.0.0` without a prefix length in the CSV file, the script will handle it like `10.0.0.0/32`. +> 1. CIDR notation with no prefix length will be handled as /32.
- For example, if you enter `10.0.0.1` without a prefix length in the CSV file, the script will handle it like `10.0.0.1/32`. > 2. The header line **`Subnets in CIDR Notation`** within the `subnets.csv` file is automatically skipped. So, there is no need to manually remove it. -> 3. Gateway input accepts 0 or 1 **ONLY** [Defaults to 0]. 0 picks the first IP address of the subnet, while 1 picks the last IP address. - -> 4. Microsoft Excel does not allow worksheet name longer than 31 characters. Worksheet names longer than 31 chars will be truncated. +> 3. The gateway input accepts 0 or 1 **ONLY** [Defaults to 0]. 0 picks the first IP address of the subnet, while 1 picks the last IP address. --- @@ -120,10 +115,6 @@ Finally, if you have a L3 switch and you want to create [SVI interfaces](https:/ ```bash $ python parse_excel.py --file .xlsx ``` -**OR** -``` -$ python parse_excel.py -f .xlsx -``` This Python script will generate a configuration file that includes all VLANs and their SVI interfaces. @@ -135,6 +126,7 @@ This Python script will generate a configuration file that includes all VLANs an **Terminal** ![Python CLI](assets/subnetting-cli.png) +_Elapsed time is about 9 seconds in here because a CIDR notation like 10.0.0.0/8 is a little bit extensive to process._ **CSV File (Input File)** ![CSV File](assets/subnets-csv.png) @@ -142,7 +134,7 @@ This Python script will generate a configuration file that includes all VLANs an **Excel File (Output File)** ![Excel Preview](assets/preview.png) -**python parse_excel.py --file .xlsx** +**python parse_excel.py -f .xlsx** ![SVI CLI](assets/svi.png) **SVI Template** From f423fa251dcf9b2b90971191668408c72690045b Mon Sep 17 00:00:00 2001 From: tes3awy Date: Sun, 25 Jul 2021 01:02:48 +0200 Subject: [PATCH 5/5] Update previews --- assets/subnetting-cli.png | Bin 4395 -> 3704 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/subnetting-cli.png b/assets/subnetting-cli.png index 3fc88afc90a0a03f0e2c957f3871ec9b3acb72f7..a5237d8c37f137ada3d836762d4bdff152fc755d 100644 GIT binary patch literal 3704 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 Qw*EfX(a={fSG9}y7vkIWyZ`_I literal 4395 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 DeoryF