sfacts 2.3.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.
sfacts/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ SFacts - Network Discovery and Fact Collection Tool
3
+
4
+ A modern network automation tool leveraging proven libraries:
5
+ - Netmiko for SSH connections and auto-detection
6
+ - NAPALM for vendor-agnostic device interaction
7
+ - ntc-templates for TextFSM parsing
8
+ - Nornir for parallel execution
9
+ """
10
+
11
+ __version__ = "2.3.0"
12
+ __author__ = "Milan Zapletal"
13
+
14
+ from .core.discovery import NetworkDiscovery
15
+ from .core.scanner import SubnetScanner
16
+
17
+ __all__ = ["NetworkDiscovery", "SubnetScanner"]
sfacts/cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Command-line interface for simple-facts network discovery tool.
3
+ """
4
+
5
+ from .discover import discover
6
+ from .main import cli
7
+
8
+ __all__ = ["cli", "discover"]
sfacts/cli/discover.py ADDED
@@ -0,0 +1,182 @@
1
+ """
2
+ Discovery command for network device scanning and identification.
3
+ """
4
+
5
+ import os
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+ import click
10
+
11
+ from ..core.discovery import NetworkDiscovery
12
+ from ..utils.auth import CredentialManager
13
+ from ..utils.logger import get_cli_logger
14
+
15
+ logger = get_cli_logger()
16
+
17
+
18
+ @click.command()
19
+ @click.option("--subnet", help="Subnet to scan (e.g., 192.168.1.0/24)")
20
+ @click.option("--tunnel-host", help="SSH tunnel host (e.g., localhost for testing)")
21
+ @click.option("--tunnel-ports", help="SSH tunnel port range (e.g., 2101-2116)")
22
+ @click.option("--username", help="SSH username (or set NETOPS_USERNAME env var)")
23
+ @click.option("--password", help="SSH password (or set NETOPS_PASSWORD env var)")
24
+ @click.option("--secret", help="Enable secret (optional, or set NETOPS_SECRET env var)")
25
+ @click.option("--output", help="Output file (default: disco_results_TIMESTAMP.json)")
26
+ @click.option("--output-dir", help="Output directory for results (creates if not exists)")
27
+ @click.option("--timeout", default=10, help="SSH timeout in seconds")
28
+ @click.option("--workers", default=20, help="Number of parallel workers")
29
+ @click.option("--no-save", is_flag=True, help="Do not save results to file")
30
+ def discover(
31
+ subnet: Optional[str],
32
+ tunnel_host: Optional[str],
33
+ tunnel_ports: Optional[str],
34
+ username: Optional[str],
35
+ password: Optional[str],
36
+ secret: Optional[str],
37
+ output: Optional[str],
38
+ output_dir: Optional[str],
39
+ timeout: int,
40
+ workers: int,
41
+ no_save: bool,
42
+ ) -> None:
43
+ """
44
+ Discover network devices and identify them using Netmiko SSHDetect.
45
+
46
+ This command supports two modes:
47
+ 1. Subnet scanning: Scans subnet for devices with open SSH ports
48
+ 2. SSH tunnel mode: Tests devices through SSH tunnel (localhost + port range)
49
+
50
+ Examples:
51
+
52
+ # Basic subnet discovery
53
+ sfacts discover --subnet 192.168.1.0/24
54
+
55
+ # Save results to specific directory
56
+ sfacts discover --subnet 192.168.1.0/24 --output-dir /path/to/results
57
+
58
+ # SSH tunnel mode for testing
59
+ sfacts discover --tunnel-host localhost --tunnel-ports 2101-2116
60
+
61
+ # High-performance scanning with custom output
62
+ sfacts discover --subnet 10.0.0.0/16 --workers 50 --timeout 15 --output-dir ./network_scans
63
+ """
64
+
65
+ logger.info(" SFacts - Network Discovery Tool")
66
+
67
+ # Validate mode selection
68
+ if not subnet and not (tunnel_host and tunnel_ports):
69
+ logger.error("Either --subnet or both --tunnel-host and --tunnel-ports must be specified")
70
+ raise click.Abort()
71
+
72
+ if subnet and (tunnel_host or tunnel_ports):
73
+ logger.error("Cannot use both subnet and tunnel mode simultaneously")
74
+ raise click.Abort()
75
+
76
+ # Display mode
77
+ if subnet:
78
+ logger.info(f"Mode: Subnet scanning - {subnet}")
79
+ else:
80
+ logger.info(f"Mode: SSH tunnel - {tunnel_host} ports {tunnel_ports}")
81
+
82
+ try:
83
+ # Get credentials
84
+ cred_manager = CredentialManager()
85
+ credentials = cred_manager.get_credentials(
86
+ username=username, password=password, secret=secret, prompt=True
87
+ )
88
+
89
+ # Validate credentials
90
+ if not cred_manager.validate_credentials(credentials):
91
+ logger.error("Invalid credentials provided")
92
+ raise click.Abort()
93
+
94
+ logger.success(f"Using credentials for user: {credentials['username']}")
95
+
96
+ # Initialize discovery engine
97
+ discovery = NetworkDiscovery(credentials=credentials, timeout=timeout, max_workers=workers)
98
+
99
+ # Perform discovery based on mode
100
+ if subnet:
101
+ # Store subnet for results
102
+ discovery._last_subnet = subnet
103
+
104
+ logger.info("\nDiscovery Parameters:")
105
+ logger.info(f" • Timeout: {timeout}s")
106
+ logger.info(f" • Workers: {workers}")
107
+ logger.info(f" • Target: {subnet}")
108
+
109
+ devices = discovery.discover_subnet(subnet)
110
+ else:
111
+ # SSH tunnel mode
112
+ logger.info("\nTunnel Discovery Parameters:")
113
+ logger.info(f" • Timeout: {timeout}s")
114
+ logger.info(f" • Workers: {workers}")
115
+ logger.info(f" • Host: {tunnel_host}")
116
+ logger.info(f" • Ports: {tunnel_ports}")
117
+
118
+ assert tunnel_host is not None and tunnel_ports is not None
119
+ devices = discovery.discover_tunnel(tunnel_host, tunnel_ports)
120
+
121
+ # Display results
122
+ if devices:
123
+ logger.success("Discovery completed successfully!")
124
+ logger.success(f"Found {len(devices)} network devices")
125
+
126
+ # Show device details
127
+ logger.info("\nDiscovered Devices:")
128
+ for device in devices:
129
+ logger.info(f" • {device['host']} - {device['device_type']}")
130
+ else:
131
+ logger.warning(f"\n No devices found in {subnet}")
132
+ logger.warning("This could mean:")
133
+ logger.warning(" • No devices have SSH enabled")
134
+ logger.warning(" • Firewall is blocking SSH access")
135
+ logger.warning(" • Credentials are incorrect")
136
+
137
+ # Save results unless disabled
138
+ if not no_save:
139
+ # Handle output directory and filename
140
+ if output_dir:
141
+ # Create output directory if it doesn't exist
142
+ os.makedirs(output_dir, exist_ok=True)
143
+
144
+ if output:
145
+ # If both output-dir and output are specified, combine them
146
+ output_path = os.path.join(output_dir, os.path.basename(output))
147
+ else:
148
+ # Generate default filename in the specified directory
149
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
150
+ output_path = os.path.join(output_dir, f"disco_results_{timestamp}.json")
151
+ else:
152
+ # Use output as-is or generate default in current directory
153
+ if not output:
154
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
155
+ output_path = f"disco_results_{timestamp}.json"
156
+ else:
157
+ output_path = output
158
+
159
+ discovery.save_results(output_path)
160
+
161
+ # Show file info
162
+ if os.path.exists(output_path):
163
+ file_size = os.path.getsize(output_path)
164
+ logger.success(f"Results saved to {output_path}")
165
+ logger.info(f" File size: {file_size:,} bytes")
166
+
167
+ # Show next steps
168
+ if devices:
169
+ logger.info("\nNext Steps:")
170
+ logger.info("• Phase 2: Fact collection (coming soon)")
171
+ logger.info("• Review the JSON output for detailed device information")
172
+ logger.info("• Use the discovered devices for network automation tasks")
173
+
174
+ logger.success("\n✅ Discovery process completed")
175
+
176
+ except KeyboardInterrupt:
177
+ logger.warning("Discovery interrupted by user")
178
+ raise click.Abort() from None
179
+ except Exception as e:
180
+ logger.error(f"✗ Discovery failed: {str(e)}")
181
+ logger.error("Aborted!")
182
+ raise click.Abort() from e
sfacts/cli/export.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ CLI command for exporting inventory from collected facts.
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from ..core.inventory import NornirInventoryExporter
15
+ from ..utils.logger import get_cli_logger
16
+
17
+ console = Console()
18
+ logger = get_cli_logger()
19
+
20
+
21
+ @click.command()
22
+ @click.option(
23
+ "--input-file",
24
+ "-i",
25
+ type=click.Path(exists=True, path_type=Path),
26
+ help="Input facts JSON file to export from",
27
+ )
28
+ @click.option(
29
+ "--output-dir",
30
+ "-o",
31
+ default="inventory",
32
+ type=click.Path(path_type=Path),
33
+ help="Output directory for inventory files (default: inventory)",
34
+ )
35
+ @click.option(
36
+ "--format",
37
+ "export_format",
38
+ type=click.Choice(["nornir"]),
39
+ default="nornir",
40
+ help="Export format (default: nornir)",
41
+ )
42
+ @click.option(
43
+ "--include-credentials/--no-credentials",
44
+ default=False,
45
+ help="Include default credentials in inventory (default: no)",
46
+ )
47
+ @click.option(
48
+ "--latest/--no-latest",
49
+ default=True,
50
+ help="Use latest facts file if no input specified (default: yes)",
51
+ )
52
+ def export_inventory(
53
+ input_file: Optional[Path],
54
+ output_dir: Path,
55
+ export_format: str,
56
+ include_credentials: bool,
57
+ latest: bool,
58
+ ) -> None:
59
+ """
60
+ Export collected device facts to inventory format.
61
+
62
+ Converts facts collection results into network automation inventory
63
+ formats like Nornir for use with automation frameworks.
64
+ """
65
+ console.print(
66
+ Panel.fit(
67
+ "[bold blue]Simple Facts - Inventory Export[/bold blue]",
68
+ border_style="blue",
69
+ )
70
+ )
71
+
72
+ try:
73
+ # Determine input file
74
+ if not input_file:
75
+ if latest:
76
+ input_file = _find_latest_facts_file()
77
+ if not input_file:
78
+ console.print("[red]❌ No facts files found in output/ directory[/red]")
79
+ raise click.Abort()
80
+ console.print(f"[green]📁 Using latest facts file: {input_file}[/green]")
81
+ else:
82
+ console.print("[red]❌ No input file specified[/red]")
83
+ raise click.Abort()
84
+
85
+ # Load facts data
86
+ console.print(f"[blue]📖 Loading facts from {input_file}...[/blue]")
87
+ with open(input_file) as f:
88
+ facts_data = json.load(f)
89
+
90
+ # Validate facts data
91
+ if "devices" not in facts_data:
92
+ console.print("[red]❌ Invalid facts file format - missing 'devices' key[/red]")
93
+ raise click.Abort()
94
+
95
+ devices_raw = facts_data["devices"]
96
+ # Convert dict to list and filter successful devices
97
+ successful_devices = []
98
+ for _device_key, device_data in devices_raw.items():
99
+ if isinstance(device_data, dict):
100
+ connection_info = device_data.get("connection_info", {})
101
+ if connection_info.get("connection_successful", False):
102
+ successful_devices.append(device_data)
103
+
104
+ if not successful_devices:
105
+ console.print("[red]❌ No successful device collections found in facts file[/red]")
106
+ raise click.Abort()
107
+
108
+ console.print(f"[green]✅ Found {len(successful_devices)} devices to export[/green]")
109
+
110
+ # Export based on format
111
+ if export_format == "nornir":
112
+ exporter = NornirInventoryExporter()
113
+
114
+ console.print(f"[blue]🔄 Exporting to Nornir format in {output_dir}/...[/blue]")
115
+ files_created = exporter.export_inventory(
116
+ facts_data, str(output_dir), include_credentials
117
+ )
118
+
119
+ # Display results
120
+ _display_export_results(files_created, exporter, facts_data)
121
+
122
+ else:
123
+ console.print(f"[red]❌ Unsupported export format: {export_format}[/red]")
124
+ raise click.Abort()
125
+
126
+ console.print("\n[bold green]🎉 Inventory export completed successfully![/bold green]")
127
+
128
+ except Exception as e:
129
+ console.print(f"[red]❌ Export failed: {e}[/red]")
130
+ raise RuntimeError(f"Export failed: {e}") from e
131
+
132
+
133
+ def _find_latest_facts_file() -> Optional[Path]:
134
+ """Find the most recent facts file in output directory."""
135
+ output_dir = Path("output")
136
+ if not output_dir.exists():
137
+ return None
138
+
139
+ facts_files = list(output_dir.glob("facts_results_*.json"))
140
+ if not facts_files:
141
+ return None
142
+
143
+ # Sort by modification time, newest first
144
+ return max(facts_files, key=lambda f: f.stat().st_mtime)
145
+
146
+
147
+ def _display_export_results(
148
+ files_created: dict, exporter: NornirInventoryExporter, facts_data: dict
149
+ ) -> None:
150
+ """Display export results in a formatted table."""
151
+
152
+ # Files created table
153
+ table = Table(title="📁 Files Created", show_header=True, header_style="bold blue")
154
+ table.add_column("File Type", style="cyan")
155
+ table.add_column("Path", style="green")
156
+ table.add_column("Size", style="yellow")
157
+
158
+ for file_type, file_path in files_created.items():
159
+ file_size = Path(file_path).stat().st_size
160
+ size_str = f"{file_size:,} bytes"
161
+ table.add_row(file_type.title(), file_path, size_str)
162
+
163
+ console.print(table)
164
+
165
+ # Summary report
166
+ summary = exporter.generate_summary_report(facts_data)
167
+ console.print(
168
+ Panel(
169
+ summary,
170
+ title="📊 Export Summary",
171
+ border_style="green",
172
+ )
173
+ )
174
+
175
+
176
+ if __name__ == "__main__":
177
+ export_inventory()
sfacts/cli/facts.py ADDED
@@ -0,0 +1,219 @@
1
+ """
2
+ Facts collection command for detailed device information gathering.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ import click
11
+
12
+ from ..core.discovery import NetworkDiscovery
13
+ from ..utils.auth import CredentialManager
14
+ from ..utils.logger import get_collector_logger
15
+
16
+ logger = get_collector_logger()
17
+
18
+
19
+ @click.command()
20
+ @click.option("--input", "input_file", help="Input discovery file (JSON)")
21
+ @click.option("--subnet", help="Subnet to discover and collect facts from")
22
+ @click.option("--username", help="SSH username (or set NETOPS_USERNAME env var)")
23
+ @click.option("--password", help="SSH password (or set NETOPS_PASSWORD env var)")
24
+ @click.option("--secret", help="Enable secret (optional, or set NETOPS_SECRET env var)")
25
+ @click.option("--output", help="Output file (default: facts_results_TIMESTAMP.json)")
26
+ @click.option("--output-dir", help="Output directory for results (creates if not exists)")
27
+ @click.option("--timeout", default=10, help="SSH timeout in seconds")
28
+ @click.option("--workers", default=10, help="Number of parallel workers")
29
+ @click.option("--no-save", is_flag=True, help="Do not save results to file")
30
+ def facts(
31
+ input_file: Optional[str],
32
+ subnet: Optional[str],
33
+ username: Optional[str],
34
+ password: Optional[str],
35
+ secret: Optional[str],
36
+ output: Optional[str],
37
+ output_dir: Optional[str],
38
+ timeout: int,
39
+ workers: int,
40
+ no_save: bool,
41
+ ) -> None:
42
+ """
43
+ Collect detailed facts from network devices.
44
+
45
+ This command can work in two modes:
46
+ 1. Use existing discovery results (--input)
47
+ 2. Discover and collect facts in one step (--subnet)
48
+
49
+ Examples:
50
+
51
+ # Collect facts from previous discovery
52
+ sfacts facts --input discovery_results.json
53
+
54
+ # Save results to specific directory
55
+ sfacts facts --input discovery_results.json --output-dir /path/to/results
56
+
57
+ # Discover and collect facts in one step
58
+ sfacts facts --subnet 192.168.1.0/24
59
+
60
+ # Combine discovery and facts with custom output directory
61
+ sfacts facts --subnet 192.168.1.0/24 --output-dir ./network_facts
62
+
63
+ # Use environment variables for credentials
64
+ export NETOPS_USERNAME=admin
65
+ export NETOPS_PASSWORD=admin
66
+ sfacts facts --subnet 10.0.0.0/24 --output-dir ./results
67
+ """
68
+
69
+ logger.info("🔍 SFacts - Enhanced Fact Collection")
70
+
71
+ try:
72
+ # Get credentials
73
+ cred_manager = CredentialManager()
74
+ credentials = cred_manager.get_credentials(
75
+ username=username, password=password, secret=secret, prompt=True
76
+ )
77
+
78
+ # Validate credentials
79
+ if not cred_manager.validate_credentials(credentials):
80
+ logger.error("✗ Invalid credentials provided")
81
+ raise click.Abort()
82
+
83
+ logger.success(f"✓ Using credentials for user: {credentials['username']}")
84
+
85
+ devices = []
86
+
87
+ # Mode 1: Load devices from existing discovery file
88
+ if input_file:
89
+ logger.info(f"📂 Loading devices from {input_file}")
90
+
91
+ if not os.path.exists(input_file):
92
+ logger.error(f"✗ Input file not found: {input_file}")
93
+ raise click.Abort()
94
+
95
+ with open(input_file) as f:
96
+ discovery_data = json.load(f)
97
+
98
+ # Extract devices from discovery format
99
+ if "devices" in discovery_data:
100
+ for host, device_info in discovery_data["devices"].items():
101
+ device_entry = {
102
+ "host": device_info.get("host", host.split(":")[0]),
103
+ "device_type": device_info.get("device_type"),
104
+ "platform": device_info.get("platform"),
105
+ "credentials": credentials,
106
+ }
107
+
108
+ # Add port if specified in device info
109
+ if "port" in device_info:
110
+ device_entry["port"] = device_info["port"]
111
+
112
+ devices.append(device_entry)
113
+
114
+ logger.success(f"✓ Loaded {len(devices)} devices from discovery file")
115
+
116
+ # Mode 2: Discover devices first, then collect facts
117
+ elif subnet:
118
+ logger.info(f"🔍 Discovering devices in {subnet}")
119
+
120
+ # Initialize discovery engine
121
+ discovery = NetworkDiscovery(
122
+ credentials=credentials, timeout=timeout, max_workers=workers
123
+ )
124
+
125
+ # Discover devices
126
+ devices = discovery.discover_subnet(subnet)
127
+
128
+ if not devices:
129
+ logger.warning(f"⚠ No devices found in {subnet}")
130
+ return
131
+
132
+ else:
133
+ logger.error("✗ Either --input or --subnet must be specified")
134
+ raise click.Abort()
135
+
136
+ if not devices:
137
+ logger.warning("⚠ No devices available for fact collection")
138
+ return
139
+
140
+ # Initialize discovery engine for fact collection
141
+ discovery = NetworkDiscovery(credentials=credentials, timeout=timeout, max_workers=workers)
142
+
143
+ # Collect detailed facts
144
+ logger.info("Fact Collection Parameters:")
145
+ logger.info(f" • Devices: {len(devices)}")
146
+ logger.info(f" • Timeout: {timeout}s")
147
+ logger.info(f" • Workers: {workers}")
148
+
149
+ fact_results = discovery.collect_facts(devices)
150
+
151
+ # Display summary
152
+ if fact_results and "collection_summary" in fact_results:
153
+ summary = fact_results["collection_summary"]
154
+
155
+ logger.success("📊 Fact Collection Summary")
156
+ logger.info(f"Total Devices: {summary.get('total_devices', 0)}")
157
+ logger.info(f"Successful Collections: {summary.get('successful_devices', 0)}")
158
+ logger.info(f"Failed Collections: {summary.get('failed_devices', 0)}")
159
+
160
+ # Show device details
161
+ if fact_results.get("devices"):
162
+ logger.info("Device Facts Collected:")
163
+ for host, device_facts in fact_results["devices"].items():
164
+ if device_facts.get("connection_info", {}).get("connection_successful", False):
165
+ basic_facts = device_facts.get("basic_facts", {})
166
+ hostname = basic_facts.get("hostname", "unknown")
167
+ model = basic_facts.get("model", "unknown")
168
+ version = basic_facts.get("software_version", "unknown")
169
+ logger.info(f" • {host} - {hostname} ({model}) - {version}")
170
+ else:
171
+ logger.error(f" • {host} - Connection failed")
172
+
173
+ # Save results unless disabled
174
+ if not no_save:
175
+ # Handle output directory and filename
176
+ if output_dir:
177
+ # Create output directory if it doesn't exist
178
+ os.makedirs(output_dir, exist_ok=True)
179
+
180
+ if output:
181
+ # If both output-dir and output are specified, combine them
182
+ output_path = os.path.join(output_dir, os.path.basename(output))
183
+ else:
184
+ # Generate default filename in the specified directory
185
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
186
+ output_path = os.path.join(output_dir, f"facts_results_{timestamp}.json")
187
+ else:
188
+ # Use output as-is or generate default in current directory
189
+ if not output:
190
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
191
+ output_path = f"facts_results_{timestamp}.json"
192
+ else:
193
+ output_path = output
194
+
195
+ with open(output_path, "w") as f:
196
+ json.dump(fact_results, f, indent=2, default=str)
197
+
198
+ logger.success(f"✅ Results saved to {output_path}")
199
+
200
+ # Show file info
201
+ if os.path.exists(output_path):
202
+ file_size = os.path.getsize(output_path)
203
+ logger.info(f"📄 File size: {file_size:,} bytes")
204
+
205
+ # Show next steps
206
+ logger.info("Enhanced Data Available:")
207
+ logger.info("• Device hostnames, models, and software versions")
208
+ logger.info("• Command outputs (show version, show inventory, etc.)")
209
+ logger.info("• Connection and timing metadata")
210
+ logger.info("• Structured device facts for automation")
211
+
212
+ except KeyboardInterrupt:
213
+ logger.warning("⚠ Fact collection interrupted by user")
214
+ raise click.Abort() from None
215
+ except Exception as e:
216
+ logger.error(f"✗ Fact collection failed: {str(e)}")
217
+ raise click.Abort() from e
218
+
219
+ logger.success("✅ Fact collection completed")
sfacts/cli/main.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ Main CLI entry point for sfacts network discovery tool.
3
+ """
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from .discover import discover
9
+ from .export import export_inventory
10
+ from .facts import facts
11
+ from .netbox import netbox
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(version="2.1.0", prog_name="sfacts")
18
+ def cli() -> None:
19
+ """
20
+ SFacts - Network Discovery and Fact Collection Tool
21
+
22
+ A modern network automation tool leveraging proven libraries:
23
+ - Netmiko for SSH connections and auto-detection
24
+ - NAPALM for vendor-agnostic device interaction
25
+ - ntc-templates for TextFSM parsing
26
+ - Nornir for parallel execution
27
+ """
28
+ pass
29
+
30
+
31
+ # Add subcommands
32
+ cli.add_command(discover)
33
+ cli.add_command(facts)
34
+ cli.add_command(export_inventory)
35
+ cli.add_command(netbox)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ cli()