lanscape 1.3.5a1__py3-none-any.whl → 1.3.5a2__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.
- lanscape/libraries/app_scope.py +0 -1
- lanscape/libraries/decorators.py +10 -5
- lanscape/libraries/errors.py +10 -0
- lanscape/libraries/ip_parser.py +73 -1
- lanscape/libraries/logger.py +29 -1
- lanscape/libraries/mac_lookup.py +5 -0
- lanscape/libraries/net_tools.py +124 -66
- lanscape/libraries/port_manager.py +83 -0
- lanscape/libraries/scan_config.py +98 -3
- lanscape/libraries/service_scan.py +3 -3
- lanscape/libraries/subnet_scan.py +104 -16
- lanscape/libraries/version_manager.py +50 -7
- lanscape/libraries/web_browser.py +75 -58
- lanscape/resources/mac_addresses/convert_csv.py +13 -2
- lanscape/resources/ports/convert_csv.py +13 -3
- lanscape/ui/app.py +24 -6
- lanscape/ui/blueprints/__init__.py +4 -1
- lanscape/ui/blueprints/api/__init__.py +2 -0
- lanscape/ui/blueprints/api/port.py +46 -0
- lanscape/ui/blueprints/api/scan.py +57 -5
- lanscape/ui/blueprints/api/tools.py +1 -0
- lanscape/ui/blueprints/web/__init__.py +4 -0
- lanscape/ui/blueprints/web/routes.py +52 -2
- lanscape/ui/main.py +1 -1
- lanscape/ui/shutdown_handler.py +5 -1
- {lanscape-1.3.5a1.dist-info → lanscape-1.3.5a2.dist-info}/METADATA +1 -1
- {lanscape-1.3.5a1.dist-info → lanscape-1.3.5a2.dist-info}/RECORD +30 -30
- {lanscape-1.3.5a1.dist-info → lanscape-1.3.5a2.dist-info}/WHEEL +0 -0
- {lanscape-1.3.5a1.dist-info → lanscape-1.3.5a2.dist-info}/licenses/LICENSE +0 -0
- {lanscape-1.3.5a1.dist-info → lanscape-1.3.5a2.dist-info}/top_level.txt +0 -0
|
@@ -1,14 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration module for network scanning operations.
|
|
3
|
+
Provides classes and utilities to configure different types of network scans
|
|
4
|
+
including ping scans, ARP scans, and port scanning.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
from typing import List, Dict
|
|
2
8
|
import ipaddress
|
|
3
|
-
from pydantic import BaseModel, Field
|
|
4
9
|
from enum import Enum
|
|
5
10
|
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
6
12
|
|
|
7
13
|
from lanscape.libraries.port_manager import PortManager
|
|
8
14
|
from lanscape.libraries.ip_parser import parse_ip_input
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
class PingConfig(BaseModel):
|
|
18
|
+
"""
|
|
19
|
+
Configuration settings for ICMP ping-based network scanning.
|
|
20
|
+
|
|
21
|
+
Controls parameters such as the number of ping attempts, count per ping,
|
|
22
|
+
timeout values, and retry delays to optimize ping scanning behavior.
|
|
23
|
+
"""
|
|
12
24
|
attempts: int = 2
|
|
13
25
|
ping_count: int = 1
|
|
14
26
|
timeout: float = 1.0
|
|
@@ -16,9 +28,24 @@ class PingConfig(BaseModel):
|
|
|
16
28
|
|
|
17
29
|
@classmethod
|
|
18
30
|
def from_dict(cls, data: dict) -> 'PingConfig':
|
|
31
|
+
"""
|
|
32
|
+
Create a PingConfig instance from a dictionary.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
data: Dictionary containing PingConfig parameters
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A new PingConfig instance with the provided settings
|
|
39
|
+
"""
|
|
19
40
|
return cls.model_validate(data)
|
|
20
41
|
|
|
21
42
|
def to_dict(self) -> dict:
|
|
43
|
+
"""
|
|
44
|
+
Convert the PingConfig instance to a dictionary.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dictionary representation of the PingConfig
|
|
48
|
+
"""
|
|
22
49
|
return self.model_dump()
|
|
23
50
|
|
|
24
51
|
def __str__(self):
|
|
@@ -39,9 +66,24 @@ class ArpConfig(BaseModel):
|
|
|
39
66
|
|
|
40
67
|
@classmethod
|
|
41
68
|
def from_dict(cls, data: dict) -> 'ArpConfig':
|
|
69
|
+
"""
|
|
70
|
+
Create an ArpConfig instance from a dictionary.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
data: Dictionary containing ArpConfig parameters
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A new ArpConfig instance with the provided settings
|
|
77
|
+
"""
|
|
42
78
|
return cls.model_validate(data)
|
|
43
79
|
|
|
44
80
|
def to_dict(self) -> dict:
|
|
81
|
+
"""
|
|
82
|
+
Convert the ArpConfig instance to a dictionary.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Dictionary representation of the ArpConfig
|
|
86
|
+
"""
|
|
45
87
|
return self.model_dump()
|
|
46
88
|
|
|
47
89
|
def __str__(self):
|
|
@@ -49,12 +91,25 @@ class ArpConfig(BaseModel):
|
|
|
49
91
|
|
|
50
92
|
|
|
51
93
|
class ScanType(Enum):
|
|
94
|
+
"""
|
|
95
|
+
Enumeration of supported network scan types.
|
|
96
|
+
|
|
97
|
+
PING: Uses ICMP echo requests to determine if hosts are alive
|
|
98
|
+
ARP: Uses Address Resolution Protocol to discover hosts on the local network
|
|
99
|
+
BOTH: Uses both PING and ARP methods for maximum coverage
|
|
100
|
+
"""
|
|
52
101
|
PING = 'ping'
|
|
53
102
|
ARP = 'arp'
|
|
54
103
|
BOTH = 'both'
|
|
55
104
|
|
|
56
105
|
|
|
57
106
|
class ScanConfig(BaseModel):
|
|
107
|
+
"""
|
|
108
|
+
Main configuration class for network scanning operations.
|
|
109
|
+
|
|
110
|
+
Contains settings for subnet targets, port ranges, thread counts,
|
|
111
|
+
scan tasks to perform, and configurations for different scan methods.
|
|
112
|
+
"""
|
|
58
113
|
subnet: str
|
|
59
114
|
port_list: str
|
|
60
115
|
t_multiplier: float = 1.0
|
|
@@ -71,11 +126,31 @@ class ScanConfig(BaseModel):
|
|
|
71
126
|
ping_config: PingConfig = Field(default_factory=PingConfig)
|
|
72
127
|
arp_config: ArpConfig = Field(default_factory=ArpConfig)
|
|
73
128
|
|
|
74
|
-
def t_cnt(self,
|
|
75
|
-
|
|
129
|
+
def t_cnt(self, thread_id: str) -> int:
|
|
130
|
+
"""
|
|
131
|
+
Calculate thread count for a specific operation based on multiplier.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
thread_id: String identifier for the thread type (e.g., 'port_scan')
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Calculated thread count for the specified operation
|
|
138
|
+
"""
|
|
139
|
+
return int(int(getattr(self, f't_cnt_{thread_id}')) * float(self.t_multiplier))
|
|
76
140
|
|
|
77
141
|
@classmethod
|
|
78
142
|
def from_dict(cls, data: dict) -> 'ScanConfig':
|
|
143
|
+
"""
|
|
144
|
+
Create a ScanConfig instance from a dictionary.
|
|
145
|
+
|
|
146
|
+
Handles special cases like converting string enum values to proper Enum types.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
data: Dictionary containing ScanConfig parameters
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A new ScanConfig instance with the provided settings
|
|
153
|
+
"""
|
|
79
154
|
# Handle special cases before validation
|
|
80
155
|
if isinstance(data.get('lookup_type'), str):
|
|
81
156
|
data['lookup_type'] = ScanType[data['lookup_type'].upper()]
|
|
@@ -83,14 +158,34 @@ class ScanConfig(BaseModel):
|
|
|
83
158
|
return cls.model_validate(data)
|
|
84
159
|
|
|
85
160
|
def to_dict(self) -> dict:
|
|
161
|
+
"""
|
|
162
|
+
Convert the ScanConfig instance to a dictionary.
|
|
163
|
+
|
|
164
|
+
Handles special cases like converting Enum values to strings.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dictionary representation of the ScanConfig
|
|
168
|
+
"""
|
|
86
169
|
dump = self.model_dump()
|
|
87
170
|
dump['lookup_type'] = self.lookup_type.value
|
|
88
171
|
return dump
|
|
89
172
|
|
|
90
173
|
def get_ports(self) -> List[int]:
|
|
174
|
+
"""
|
|
175
|
+
Get the list of ports to scan based on the configured port list name.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List of port numbers to scan
|
|
179
|
+
"""
|
|
91
180
|
return PortManager().get_port_list(self.port_list).keys()
|
|
92
181
|
|
|
93
182
|
def parse_subnet(self) -> List[ipaddress.IPv4Network]:
|
|
183
|
+
"""
|
|
184
|
+
Parse the configured subnet string into IPv4Network objects.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of IPv4Network objects representing the target networks
|
|
188
|
+
"""
|
|
94
189
|
return parse_ip_input(self.subnet)
|
|
95
190
|
|
|
96
191
|
def __str__(self):
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
"""Service scanning module for identifying services running on network ports."""
|
|
1
2
|
import asyncio
|
|
2
3
|
import logging
|
|
3
4
|
import traceback
|
|
4
|
-
from .app_scope import ResourceManager
|
|
5
|
+
from lanscape.libraries.app_scope import ResourceManager
|
|
5
6
|
|
|
6
7
|
log = logging.getLogger('ServiceScan')
|
|
7
8
|
SERVICES = ResourceManager('services').get_jsonc('definitions.jsonc')
|
|
@@ -24,8 +25,7 @@ def scan_service(ip: str, port: int, timeout=10) -> str:
|
|
|
24
25
|
reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=5)
|
|
25
26
|
|
|
26
27
|
# Send a probe appropriate for common services
|
|
27
|
-
probe = "GET / HTTP/1.1\r\nHost: {}\r\n\r\n".
|
|
28
|
-
ip).encode("utf-8")
|
|
28
|
+
probe = f"GET / HTTP/1.1\r\nHost: {ip}\r\n\r\n".encode("utf-8")
|
|
29
29
|
writer.write(probe)
|
|
30
30
|
await writer.drain()
|
|
31
31
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"""
|
|
2
|
+
Network subnet scanning module for LANscape.
|
|
3
|
+
Provides classes for performing network discovery, device scanning, and port scanning.
|
|
4
|
+
Handles scan management, result tracking, and scan termination.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
3
8
|
import os
|
|
4
9
|
import json
|
|
5
10
|
import uuid
|
|
@@ -7,33 +12,49 @@ import logging
|
|
|
7
12
|
import ipaddress
|
|
8
13
|
import traceback
|
|
9
14
|
import threading
|
|
10
|
-
from time import time
|
|
11
|
-
from time import sleep
|
|
15
|
+
from time import time, sleep
|
|
12
16
|
from typing import List, Union
|
|
13
|
-
from tabulate import tabulate
|
|
14
17
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
from
|
|
19
|
+
# Third-party imports
|
|
20
|
+
from tabulate import tabulate
|
|
21
|
+
|
|
22
|
+
# Local imports
|
|
23
|
+
from lanscape.libraries.scan_config import ScanConfig
|
|
24
|
+
from lanscape.libraries.decorators import job_tracker, terminator, JobStatsMixin
|
|
25
|
+
from lanscape.libraries.net_tools import Device, is_arp_supported
|
|
26
|
+
from lanscape.libraries.errors import SubnetScanTerminationFailure
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
class SubnetScanner(JobStatsMixin):
|
|
30
|
+
"""
|
|
31
|
+
Scans a subnet for devices and open ports.
|
|
32
|
+
|
|
33
|
+
Manages the scanning process including device discovery and port scanning.
|
|
34
|
+
Tracks scan progress and provides mechanisms for controlled termination.
|
|
35
|
+
"""
|
|
36
|
+
|
|
21
37
|
def __init__(
|
|
22
38
|
self,
|
|
23
39
|
config: ScanConfig
|
|
24
40
|
):
|
|
41
|
+
# Config and network properties
|
|
25
42
|
self.cfg = config
|
|
26
43
|
self.subnet = config.parse_subnet()
|
|
27
44
|
self.ports: List[int] = config.get_ports()
|
|
28
|
-
self.running = False
|
|
29
45
|
self.subnet_str = config.subnet
|
|
30
46
|
|
|
47
|
+
# Status properties
|
|
48
|
+
self.running = False
|
|
31
49
|
self.uid = str(uuid.uuid4())
|
|
32
50
|
self.results = ScannerResults(self)
|
|
33
51
|
self.log: logging.Logger = logging.getLogger('SubnetScanner')
|
|
52
|
+
|
|
53
|
+
# Initial logging
|
|
34
54
|
if not is_arp_supported():
|
|
35
55
|
self.log.warning(
|
|
36
|
-
'ARP is not supported with the active runtime context.
|
|
56
|
+
'ARP is not supported with the active runtime context. '
|
|
57
|
+
'Device discovery will be limited to ping responses.')
|
|
37
58
|
self.log.debug(f'Instantiated with uid: {self.uid}')
|
|
38
59
|
self.log.debug(
|
|
39
60
|
f'Port Count: {len(self.ports)} | Device Count: {len(self.subnet)}')
|
|
@@ -68,16 +89,36 @@ class SubnetScanner(JobStatsMixin):
|
|
|
68
89
|
return self.results
|
|
69
90
|
|
|
70
91
|
def terminate(self):
|
|
92
|
+
"""
|
|
93
|
+
Terminate the scan operation.
|
|
94
|
+
|
|
95
|
+
Attempts a graceful shutdown of all scan operations and waits for running
|
|
96
|
+
tasks to complete. Raises an exception if termination takes too long.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
bool: True if terminated successfully
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
SubnetScanTerminationFailure: If the scan cannot be terminated within the timeout
|
|
103
|
+
"""
|
|
71
104
|
self.running = False
|
|
72
105
|
self._set_stage('terminating')
|
|
73
|
-
for
|
|
74
|
-
if not
|
|
106
|
+
for _ in range(20):
|
|
107
|
+
if not self.job_stats.running:
|
|
75
108
|
self._set_stage('terminated')
|
|
76
109
|
return True
|
|
77
110
|
sleep(.5)
|
|
78
111
|
raise SubnetScanTerminationFailure(self.job_stats.running)
|
|
79
112
|
|
|
80
113
|
def calc_percent_complete(self) -> int: # 0 - 100
|
|
114
|
+
"""
|
|
115
|
+
Calculate the percentage completion of the scan.
|
|
116
|
+
|
|
117
|
+
Uses scan statistics and job timing information to estimate progress.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
int: Completion percentage (0-100)
|
|
121
|
+
"""
|
|
81
122
|
if not self.running:
|
|
82
123
|
return 100
|
|
83
124
|
|
|
@@ -85,7 +126,7 @@ class SubnetScanner(JobStatsMixin):
|
|
|
85
126
|
avg_host_detail_sec = self.job_stats.timing.get(
|
|
86
127
|
'_get_host_details', 4.5)
|
|
87
128
|
# assume 10% alive percentage if the scan just started
|
|
88
|
-
if
|
|
129
|
+
if self.results.devices and self.results.devices_scanned:
|
|
89
130
|
est_subnet_alive_percent = (
|
|
90
131
|
# avoid div 0
|
|
91
132
|
len(self.results.devices)) / (self.results.devices_scanned)
|
|
@@ -213,38 +254,65 @@ class SubnetScanner(JobStatsMixin):
|
|
|
213
254
|
|
|
214
255
|
|
|
215
256
|
class ScannerResults:
|
|
257
|
+
"""
|
|
258
|
+
Stores and manages the results of a subnet scan.
|
|
259
|
+
|
|
260
|
+
Tracks devices found, scan statistics, and provides export functionality
|
|
261
|
+
for scan results. Also handles runtime calculation and progress tracking.
|
|
262
|
+
"""
|
|
263
|
+
|
|
216
264
|
def __init__(self, scan: SubnetScanner):
|
|
265
|
+
# Parent reference and identifiers
|
|
217
266
|
self.scan = scan
|
|
218
267
|
self.port_list: str = scan.cfg.port_list
|
|
219
268
|
self.subnet: str = scan.subnet_str
|
|
220
269
|
self.uid = scan.uid
|
|
221
270
|
|
|
271
|
+
# Scan statistics
|
|
222
272
|
self.devices_total: int = len(list(scan.subnet))
|
|
223
273
|
self.devices_scanned: int = 0
|
|
224
274
|
self.devices: List[Device] = []
|
|
275
|
+
self.devices_alive = 0
|
|
225
276
|
|
|
277
|
+
# Status tracking
|
|
226
278
|
self.errors: List[str] = []
|
|
227
279
|
self.running: bool = False
|
|
228
280
|
self.start_time: float = time()
|
|
229
281
|
self.end_time: int = None
|
|
230
282
|
self.stage = 'instantiated'
|
|
283
|
+
self.run_time = 0
|
|
231
284
|
|
|
285
|
+
# Logging
|
|
232
286
|
self.log = logging.getLogger('ScannerResults')
|
|
233
287
|
self.log.debug(f'Instantiated Logger For Scan: {self.scan.uid}')
|
|
234
288
|
|
|
235
289
|
def scanned(self):
|
|
290
|
+
"""
|
|
291
|
+
Increment the count of scanned devices.
|
|
292
|
+
"""
|
|
236
293
|
self.devices_scanned += 1
|
|
237
294
|
|
|
238
295
|
def get_runtime(self):
|
|
296
|
+
"""
|
|
297
|
+
Calculate the runtime of the scan in seconds.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
int: Runtime in seconds
|
|
301
|
+
"""
|
|
239
302
|
if self.scan.running:
|
|
240
303
|
return int(time() - self.start_time)
|
|
241
304
|
return int(self.end_time - self.start_time)
|
|
242
305
|
|
|
243
306
|
def export(self, out_type=dict) -> Union[str, dict]:
|
|
244
307
|
"""
|
|
245
|
-
|
|
246
|
-
"""
|
|
308
|
+
Export scan results in the specified format.
|
|
247
309
|
|
|
310
|
+
Args:
|
|
311
|
+
out_type: The output type (dict or str)
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Union[str, dict]: Scan results in the specified format
|
|
315
|
+
"""
|
|
248
316
|
self.running = self.scan.running
|
|
249
317
|
self.run_time = int(round(time() - self.start_time, 0))
|
|
250
318
|
self.devices_alive = len(self.devices)
|
|
@@ -255,9 +323,9 @@ class ScannerResults:
|
|
|
255
323
|
out['cfg'] = vars(self.scan.cfg)
|
|
256
324
|
|
|
257
325
|
devices: List[Device] = out.pop('devices')
|
|
258
|
-
|
|
326
|
+
sorted_devices = sorted(
|
|
259
327
|
devices, key=lambda obj: ipaddress.IPv4Address(obj.ip))
|
|
260
|
-
out['devices'] = [device.dict() for device in
|
|
328
|
+
out['devices'] = [device.dict() for device in sorted_devices]
|
|
261
329
|
|
|
262
330
|
if out_type == str:
|
|
263
331
|
return json.dumps(out, default=str, indent=2)
|
|
@@ -304,6 +372,15 @@ class ScanManager:
|
|
|
304
372
|
self.log = logging.getLogger('ScanManager')
|
|
305
373
|
|
|
306
374
|
def new_scan(self, config: ScanConfig) -> SubnetScanner:
|
|
375
|
+
"""
|
|
376
|
+
Create and start a new scan with the given configuration.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
config: The scan configuration
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
SubnetScanner: The newly created scan instance
|
|
383
|
+
"""
|
|
307
384
|
scan = SubnetScanner(config)
|
|
308
385
|
self._start(scan)
|
|
309
386
|
self.log.info(f'Scan started - {config}')
|
|
@@ -317,6 +394,7 @@ class ScanManager:
|
|
|
317
394
|
for scan in self.scans:
|
|
318
395
|
if scan.uid == scan_id:
|
|
319
396
|
return scan
|
|
397
|
+
return None # Explicitly return None for consistency
|
|
320
398
|
|
|
321
399
|
def terminate_scans(self):
|
|
322
400
|
"""
|
|
@@ -327,12 +405,22 @@ class ScanManager:
|
|
|
327
405
|
scan.terminate()
|
|
328
406
|
|
|
329
407
|
def wait_until_complete(self, scan_id: str) -> SubnetScanner:
|
|
408
|
+
"""Wait for a scan to complete."""
|
|
330
409
|
scan = self.get_scan(scan_id)
|
|
331
410
|
while scan.running:
|
|
332
411
|
sleep(.5)
|
|
333
412
|
return scan
|
|
334
413
|
|
|
335
414
|
def _start(self, scan: SubnetScanner):
|
|
415
|
+
"""
|
|
416
|
+
Start a scan in a separate thread.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
scan: The scan to start
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Thread: The thread running the scan
|
|
423
|
+
"""
|
|
336
424
|
t = threading.Thread(target=scan.start)
|
|
337
425
|
t.start()
|
|
338
426
|
return t
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Version management module for LANscape.
|
|
3
|
+
Handles version checking, update detection, and retrieving package information
|
|
4
|
+
from both local installation and PyPI repository.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
import logging
|
|
2
|
-
import requests
|
|
3
8
|
import traceback
|
|
4
9
|
from importlib.metadata import version, PackageNotFoundError
|
|
5
10
|
from random import randint
|
|
6
11
|
|
|
12
|
+
import requests
|
|
13
|
+
|
|
7
14
|
from .app_scope import is_local_run
|
|
8
15
|
|
|
9
16
|
log = logging.getLogger('VersionManager')
|
|
@@ -11,10 +18,23 @@ log = logging.getLogger('VersionManager')
|
|
|
11
18
|
PACKAGE = 'lanscape'
|
|
12
19
|
LOCAL_VERSION = '0.0.0'
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
# Used to cache PyPI version during runtime
|
|
22
|
+
LATEST_VERSION = None
|
|
15
23
|
|
|
16
24
|
|
|
17
25
|
def is_update_available(package=PACKAGE) -> bool:
|
|
26
|
+
"""
|
|
27
|
+
Check if an update is available for the package.
|
|
28
|
+
|
|
29
|
+
Compares the installed version with the latest version available on PyPI.
|
|
30
|
+
Ignores pre-release versions (alpha/beta) and local development installs.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
package: The package name to check for updates
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Boolean indicating if an update is available
|
|
37
|
+
"""
|
|
18
38
|
installed = get_installed_version(package)
|
|
19
39
|
available = lookup_latest_version(package)
|
|
20
40
|
|
|
@@ -30,23 +50,46 @@ def is_update_available(package=PACKAGE) -> bool:
|
|
|
30
50
|
|
|
31
51
|
|
|
32
52
|
def lookup_latest_version(package=PACKAGE):
|
|
53
|
+
"""
|
|
54
|
+
Retrieve the latest version of the package from PyPI.
|
|
55
|
+
|
|
56
|
+
Caches the result for subsequent calls during the same runtime.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
package: The package name to lookup
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The latest version string from PyPI or None if retrieval fails
|
|
63
|
+
"""
|
|
33
64
|
# Fetch the latest version from PyPI
|
|
34
|
-
global
|
|
35
|
-
if not
|
|
65
|
+
global LATEST_VERSION # pylint: disable=global-statement
|
|
66
|
+
if not LATEST_VERSION:
|
|
36
67
|
no_cache = f'?cachebust={randint(0, 6969)}'
|
|
37
68
|
url = f"https://pypi.org/pypi/{package}/json{no_cache}"
|
|
38
69
|
try:
|
|
39
70
|
response = requests.get(url, timeout=5)
|
|
40
71
|
response.raise_for_status() # Raise an exception for HTTP errors
|
|
41
|
-
|
|
42
|
-
log.debug(f'Latest pypi version: {
|
|
72
|
+
LATEST_VERSION = response.json()['info']['version']
|
|
73
|
+
log.debug(f'Latest pypi version: {LATEST_VERSION}')
|
|
43
74
|
except BaseException:
|
|
44
75
|
log.debug(traceback.format_exc())
|
|
45
76
|
log.warning('Unable to fetch package version from PyPi')
|
|
46
|
-
return
|
|
77
|
+
return LATEST_VERSION
|
|
47
78
|
|
|
48
79
|
|
|
49
80
|
def get_installed_version(package=PACKAGE):
|
|
81
|
+
"""
|
|
82
|
+
Get the installed version of the package.
|
|
83
|
+
|
|
84
|
+
Returns the current installed version or a default local version
|
|
85
|
+
if running in development mode or if the package is not found.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
package: The package name to check
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The installed version string or LOCAL_VERSION for local development
|
|
92
|
+
"""
|
|
50
93
|
if not is_local_run():
|
|
51
94
|
try:
|
|
52
95
|
return version(package)
|
|
@@ -37,7 +37,7 @@ def open_webapp(url: str) -> bool:
|
|
|
37
37
|
|
|
38
38
|
if time.time() - start < 2:
|
|
39
39
|
log.debug(
|
|
40
|
-
|
|
40
|
+
'Unable to hook into closure of UI, listening for flask shutdown')
|
|
41
41
|
return False
|
|
42
42
|
return True
|
|
43
43
|
|
|
@@ -49,70 +49,26 @@ def open_webapp(url: str) -> bool:
|
|
|
49
49
|
success = webbrowser.open(url)
|
|
50
50
|
log.debug(f'Opened {url} in browser tab: {success}')
|
|
51
51
|
if not success:
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
# pylint: disable=raise-missing-from
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
'Unknown error while opening browser tab') from e
|
|
55
|
+
except Exception as e2:
|
|
54
56
|
log.warning(
|
|
55
|
-
|
|
56
|
-
log.debug(f'As tab error: {
|
|
57
|
+
'Exhausted all options to open browser, you need to open manually')
|
|
58
|
+
log.debug(f'As tab error: {e2}')
|
|
57
59
|
log.info(f'LANScape UI is running on {url}')
|
|
58
60
|
return False
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def get_default_browser_executable() -> Optional[str]:
|
|
64
|
+
"""Platform-agnostic method to get the default browser executable path."""
|
|
62
65
|
if sys.platform.startswith("win"):
|
|
63
66
|
return windows_get_browser_from_registry()
|
|
64
67
|
|
|
68
|
+
if sys.platform.startswith("linux"):
|
|
69
|
+
return linux_get_browser_executable()
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
# First, find the .desktop file name
|
|
68
|
-
desktop_file = None
|
|
69
|
-
try:
|
|
70
|
-
# Try xdg-mime
|
|
71
|
-
p = subprocess.run(
|
|
72
|
-
["xdg-mime", "query", "default", "x-scheme-handler/http"],
|
|
73
|
-
capture_output=True, text=True,
|
|
74
|
-
check=True
|
|
75
|
-
)
|
|
76
|
-
desktop_file = p.stdout.strip()
|
|
77
|
-
except subprocess.CalledProcessError:
|
|
78
|
-
pass
|
|
79
|
-
|
|
80
|
-
if not desktop_file:
|
|
81
|
-
# Fallback to xdg-settings
|
|
82
|
-
try:
|
|
83
|
-
p = subprocess.run(
|
|
84
|
-
["xdg-settings", "get", "default-web-browser"],
|
|
85
|
-
capture_output=True, text=True,
|
|
86
|
-
check=True
|
|
87
|
-
)
|
|
88
|
-
desktop_file = p.stdout.strip()
|
|
89
|
-
except subprocess.CalledProcessError:
|
|
90
|
-
pass
|
|
91
|
-
|
|
92
|
-
# Final fallback: BROWSER environment variable
|
|
93
|
-
if not desktop_file:
|
|
94
|
-
return os.environ.get("BROWSER")
|
|
95
|
-
|
|
96
|
-
# Look for that .desktop file in standard locations
|
|
97
|
-
search_paths = [
|
|
98
|
-
os.path.expanduser("~/.local/share/applications"),
|
|
99
|
-
"/usr/local/share/applications",
|
|
100
|
-
"/usr/share/applications",
|
|
101
|
-
]
|
|
102
|
-
for path in search_paths:
|
|
103
|
-
full_path = os.path.join(path, desktop_file)
|
|
104
|
-
if os.path.isfile(full_path):
|
|
105
|
-
with open(full_path, encoding="utf-8", errors="ignore") as f:
|
|
106
|
-
for line in f:
|
|
107
|
-
if line.startswith("Exec="):
|
|
108
|
-
exec_cmd = line[len("Exec="):].strip()
|
|
109
|
-
# strip arguments like “%u”, “--flag”, etc.
|
|
110
|
-
exec_cmd = exec_cmd.split()[0]
|
|
111
|
-
exec_cmd = exec_cmd.split("%")[0]
|
|
112
|
-
return exec_cmd
|
|
113
|
-
return None
|
|
114
|
-
|
|
115
|
-
elif sys.platform.startswith("darwin"):
|
|
71
|
+
if sys.platform.startswith("darwin"):
|
|
116
72
|
# macOS: try to find Chrome first for app mode support, fallback to default
|
|
117
73
|
try:
|
|
118
74
|
p = subprocess.run(
|
|
@@ -128,14 +84,75 @@ def get_default_browser_executable() -> Optional[str]:
|
|
|
128
84
|
# Fallback to system default
|
|
129
85
|
return "/usr/bin/open"
|
|
130
86
|
|
|
131
|
-
|
|
132
|
-
|
|
87
|
+
# Unsupported platform
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def linux_get_browser_executable() -> Optional[str]:
|
|
92
|
+
"""Get the default web browser executable path on Linux."""
|
|
93
|
+
# First, find the .desktop file name
|
|
94
|
+
desktop_file = None
|
|
95
|
+
try:
|
|
96
|
+
# Try xdg-mime
|
|
97
|
+
p = subprocess.run(
|
|
98
|
+
["xdg-mime", "query", "default", "x-scheme-handler/http"],
|
|
99
|
+
capture_output=True, text=True,
|
|
100
|
+
check=True
|
|
101
|
+
)
|
|
102
|
+
desktop_file = p.stdout.strip()
|
|
103
|
+
except subprocess.CalledProcessError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
if not desktop_file:
|
|
107
|
+
# Fallback to xdg-settings
|
|
108
|
+
try:
|
|
109
|
+
p = subprocess.run(
|
|
110
|
+
["xdg-settings", "get", "default-web-browser"],
|
|
111
|
+
capture_output=True, text=True,
|
|
112
|
+
check=True
|
|
113
|
+
)
|
|
114
|
+
desktop_file = p.stdout.strip()
|
|
115
|
+
except subprocess.CalledProcessError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Final fallback: BROWSER environment variable
|
|
119
|
+
if not desktop_file:
|
|
120
|
+
return os.environ.get("BROWSER")
|
|
121
|
+
|
|
122
|
+
# Look for that .desktop file in standard locations
|
|
123
|
+
search_paths = [
|
|
124
|
+
os.path.expanduser("~/.local/share/applications"),
|
|
125
|
+
"/usr/local/share/applications",
|
|
126
|
+
"/usr/share/applications",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
exec_cmd = None
|
|
130
|
+
for path in search_paths:
|
|
131
|
+
full_path = os.path.join(path, desktop_file)
|
|
132
|
+
if os.path.isfile(full_path):
|
|
133
|
+
with open(full_path, encoding="utf-8", errors="ignore") as f:
|
|
134
|
+
for line in f:
|
|
135
|
+
if line.startswith("Exec="):
|
|
136
|
+
exec_cmd = line[len("Exec="):].strip()
|
|
137
|
+
# strip arguments like "%u", "--flag", etc.
|
|
138
|
+
exec_cmd = exec_cmd.split()[0]
|
|
139
|
+
exec_cmd = exec_cmd.split("%")[0]
|
|
140
|
+
return exec_cmd
|
|
141
|
+
|
|
142
|
+
return exec_cmd
|
|
133
143
|
|
|
134
144
|
|
|
135
145
|
def windows_get_browser_from_registry() -> Optional[str]:
|
|
136
146
|
"""Get the default web browser executable path on Windows."""
|
|
147
|
+
# Import winreg only on Windows platforms
|
|
148
|
+
if not sys.platform.startswith("win"):
|
|
149
|
+
return None
|
|
137
150
|
|
|
138
|
-
|
|
151
|
+
try:
|
|
152
|
+
import winreg # pylint: disable=import-outside-toplevel
|
|
153
|
+
except ImportError:
|
|
154
|
+
log.debug("winreg module not available")
|
|
155
|
+
return None
|
|
139
156
|
|
|
140
157
|
def get_reg(base, path, key=None):
|
|
141
158
|
"""Helper function to read a registry key."""
|