netforge-network-toolkit 0.1.0__tar.gz

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.
Files changed (25) hide show
  1. netforge_network_toolkit-0.1.0/PKG-INFO +28 -0
  2. netforge_network_toolkit-0.1.0/README.md +17 -0
  3. netforge_network_toolkit-0.1.0/pyproject.toml +21 -0
  4. netforge_network_toolkit-0.1.0/setup.cfg +4 -0
  5. netforge_network_toolkit-0.1.0/src/netforge/__init__.py +19 -0
  6. netforge_network_toolkit-0.1.0/src/netforge/diagnostics.py +180 -0
  7. netforge_network_toolkit-0.1.0/src/netforge/http.py +237 -0
  8. netforge_network_toolkit-0.1.0/src/netforge/network.py +100 -0
  9. netforge_network_toolkit-0.1.0/src/netforge/ports.py +152 -0
  10. netforge_network_toolkit-0.1.0/src/netforge/servers.py +336 -0
  11. netforge_network_toolkit-0.1.0/src/netforge/sockets.py +197 -0
  12. netforge_network_toolkit-0.1.0/src/netforge/system.py +26 -0
  13. netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/PKG-INFO +28 -0
  14. netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/SOURCES.txt +28 -0
  15. netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/dependency_links.txt +1 -0
  16. netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/requires.txt +2 -0
  17. netforge_network_toolkit-0.1.0/src/netforge_network_toolkit.egg-info/top_level.txt +1 -0
  18. netforge_network_toolkit-0.1.0/tests/test_diagnostics.py +60 -0
  19. netforge_network_toolkit-0.1.0/tests/test_http.py +65 -0
  20. netforge_network_toolkit-0.1.0/tests/test_network.py +34 -0
  21. netforge_network_toolkit-0.1.0/tests/test_ports.py +68 -0
  22. netforge_network_toolkit-0.1.0/tests/test_servers.py +82 -0
  23. netforge_network_toolkit-0.1.0/tests/test_sockets.py +74 -0
  24. netforge_network_toolkit-0.1.0/tests/test_system.py +32 -0
  25. netforge_network_toolkit-0.1.0/tests/test_udp.py +61 -0
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: netforge-network-toolkit
3
+ Version: 0.1.0
4
+ Summary: A Python network and system toolkit
5
+ Author: NetForge Contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: psutil>=5.9
10
+ Requires-Dist: certifi>=2024.2.2
11
+
12
+ # NetForge
13
+
14
+ Python Network & System Toolkit.
15
+
16
+ ## Features
17
+
18
+ - System information
19
+ - Network interfaces
20
+ - TCP and UDP sockets
21
+ - HTTP requests
22
+ - Port diagnostics
23
+ - Server creation
24
+ - Network diagnostics
25
+
26
+ ## Installation
27
+
28
+ pip install netforge
@@ -0,0 +1,17 @@
1
+ # NetForge
2
+
3
+ Python Network & System Toolkit.
4
+
5
+ ## Features
6
+
7
+ - System information
8
+ - Network interfaces
9
+ - TCP and UDP sockets
10
+ - HTTP requests
11
+ - Port diagnostics
12
+ - Server creation
13
+ - Network diagnostics
14
+
15
+ ## Installation
16
+
17
+ pip install netforge
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "netforge-network-toolkit"
7
+ readme = { file = "README.md", content-type = "text/markdown" }
8
+ version = "0.1.0"
9
+ description = "A Python network and system toolkit"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "psutil>=5.9",
13
+ "certifi>=2024.2.2",
14
+ ]
15
+ license = "MIT"
16
+ authors = [
17
+ {name = "NetForge Contributors"}
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src/"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ from . import system
2
+ from . import network
3
+ from . import sockets
4
+ from . import http
5
+ from . import ports
6
+ from . import servers
7
+ from . import diagnostics
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "system",
13
+ "network",
14
+ "sockets",
15
+ "http",
16
+ "ports",
17
+ "servers",
18
+ "diagnostics",
19
+ ]
@@ -0,0 +1,180 @@
1
+
2
+ """
3
+ NetForge - Network Diagnostics
4
+
5
+ DNS resolution, TCP connectivity checks and port diagnostics.
6
+ """
7
+
8
+ import socket
9
+ import time
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from . import ports
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DNSInfo:
18
+ hostname: str
19
+ ipv4: tuple[str, ...]
20
+ ipv6: tuple[str, ...]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ConnectivityResult:
25
+ host: str
26
+ port: int
27
+ reachable: bool
28
+ latency_ms: float | None = None
29
+ error: str | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DiagnosticReport:
34
+ host: str
35
+ dns: DNSInfo | None
36
+ connectivity: ConnectivityResult
37
+ ports: tuple[ports.PortResult, ...]
38
+
39
+ @property
40
+ def reachable(self) -> bool:
41
+ return self.connectivity.reachable
42
+
43
+
44
+ def resolve(host: str) -> DNSInfo:
45
+ """
46
+ Resolve a hostname into IPv4 and IPv6 addresses.
47
+ """
48
+
49
+ if not isinstance(host, str) or not host.strip():
50
+ raise ValueError("Host must be a non-empty string")
51
+
52
+ try:
53
+ records = socket.getaddrinfo(
54
+ host,
55
+ None,
56
+ family=socket.AF_UNSPEC,
57
+ type=socket.SOCK_STREAM,
58
+ )
59
+ except socket.gaierror as error:
60
+ raise OSError(f"DNS resolution failed for {host}: {error}") from error
61
+
62
+ ipv4 = set()
63
+ ipv6 = set()
64
+
65
+ for family, _, _, _, address in records:
66
+ if family == socket.AF_INET:
67
+ ipv4.add(address[0])
68
+ elif family == socket.AF_INET6:
69
+ ipv6.add(address[0])
70
+
71
+ return DNSInfo(
72
+ hostname=host,
73
+ ipv4=tuple(sorted(ipv4)),
74
+ ipv6=tuple(sorted(ipv6)),
75
+ )
76
+
77
+
78
+ def check_connectivity(
79
+ host: str,
80
+ port: int = 443,
81
+ timeout: float = 3.0,
82
+ ) -> ConnectivityResult:
83
+ """
84
+ Test TCP connectivity to a host and port.
85
+
86
+ This is a TCP connection test, not an ICMP ping.
87
+ """
88
+
89
+ if not isinstance(host, str) or not host.strip():
90
+ raise ValueError("Host must be a non-empty string")
91
+
92
+ if (
93
+ isinstance(port, bool)
94
+ or not isinstance(port, int)
95
+ or not 1 <= port <= 65535
96
+ ):
97
+ raise ValueError("Port must be between 1 and 65535")
98
+
99
+ if timeout <= 0:
100
+ raise ValueError("Timeout must be greater than zero")
101
+
102
+ start = time.perf_counter()
103
+
104
+ try:
105
+ with socket.create_connection(
106
+ (host, port),
107
+ timeout=timeout,
108
+ ):
109
+ latency = (time.perf_counter() - start) * 1000
110
+
111
+ return ConnectivityResult(
112
+ host=host,
113
+ port=port,
114
+ reachable=True,
115
+ latency_ms=round(latency, 2),
116
+ )
117
+
118
+ except OSError as error:
119
+ return ConnectivityResult(
120
+ host=host,
121
+ port=port,
122
+ reachable=False,
123
+ error=str(error),
124
+ )
125
+
126
+
127
+ def inspect(
128
+ host: str,
129
+ scan_ports: tuple[int, ...] | list[int] = (80, 443),
130
+ connectivity_port: int = 443,
131
+ timeout: float = 3.0,
132
+ max_workers: int = 10,
133
+ ) -> DiagnosticReport:
134
+ """
135
+ Generate a network diagnostic report for a host.
136
+
137
+ Args:
138
+ host: Hostname or IP address.
139
+ scan_ports: TCP ports to inspect.
140
+ connectivity_port: Port used for the connectivity test.
141
+ timeout: Timeout for each network operation.
142
+ max_workers: Maximum concurrent port checks.
143
+
144
+ Returns:
145
+ A DiagnosticReport containing DNS, connectivity and port results.
146
+ """
147
+
148
+ if not isinstance(host, str) or not host.strip():
149
+ raise ValueError("Host must be a non-empty string")
150
+
151
+ if timeout <= 0:
152
+ raise ValueError("Timeout must be greater than zero")
153
+
154
+ dns_info = None
155
+
156
+ try:
157
+ dns_info = resolve(host)
158
+ except OSError:
159
+ # Connectivity and port checks may still work with an IP address.
160
+ pass
161
+
162
+ connectivity = check_connectivity(
163
+ host,
164
+ port=connectivity_port,
165
+ timeout=timeout,
166
+ )
167
+
168
+ port_results = ports.scan_ports(
169
+ host,
170
+ scan_ports,
171
+ timeout=timeout,
172
+ max_workers=max_workers,
173
+ )
174
+
175
+ return DiagnosticReport(
176
+ host=host,
177
+ dns=dns_info,
178
+ connectivity=connectivity,
179
+ ports=tuple(port_results),
180
+ )
@@ -0,0 +1,237 @@
1
+
2
+ import json as json_module
3
+
4
+ import urllib.request
5
+ import urllib.error
6
+
7
+ from urllib.parse import urlencode, urlsplit, urlunsplit
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Any
10
+ import ssl
11
+ import warnings
12
+ import certifi
13
+
14
+ class HTTPError(Exception):
15
+ """Raised when an HTTP response has an unsuccessful status."""
16
+
17
+ def __init__(self, status_code: int, url: str, body: str = ""):
18
+ self.status_code = status_code
19
+ self.url = url
20
+ self.body = body
21
+
22
+ super().__init__(
23
+ f"HTTP {status_code} returned by {url}"
24
+ )
25
+
26
+
27
+ class NetworkError(Exception):
28
+ """Raised when an HTTP request cannot reach its destination."""
29
+
30
+
31
+ @dataclass
32
+ class HTTPResponse:
33
+ """Represents an HTTP response."""
34
+
35
+ status_code: int
36
+ url: str
37
+ headers: dict[str, str]
38
+ content: bytes
39
+
40
+ @property
41
+ def ok(self) -> bool:
42
+ return 200 <= self.status_code < 400
43
+
44
+ @property
45
+ def text(self) -> str:
46
+ content_type = self.headers.get("Content-Type", "")
47
+
48
+ charset = "utf-8"
49
+
50
+ for part in content_type.split(";")[1:]:
51
+ key, separator, value = part.strip().partition("=")
52
+
53
+ if separator and key.lower() == "charset":
54
+ charset = value.strip("\"'")
55
+ break
56
+
57
+ try:
58
+ return self.content.decode(charset)
59
+ except LookupError:
60
+ return self.content.decode("utf-8", errors="replace")
61
+
62
+ def json(self) -> Any:
63
+ """Decode the response as JSON."""
64
+ return json_module.loads(self.text)
65
+
66
+ def raise_for_status(self):
67
+ """Raise HTTPError if the status is unsuccessful."""
68
+
69
+ if not self.ok:
70
+ raise HTTPError(
71
+ self.status_code,
72
+ self.url,
73
+ self.text
74
+ )
75
+
76
+
77
+ def _append_params(url: str, params: Optional[dict]) -> str:
78
+ """Append query parameters to an URL."""
79
+
80
+ if not params:
81
+ return url
82
+
83
+ parts = urlsplit(url)
84
+ query = urlencode(params, doseq=True)
85
+
86
+ if parts.query:
87
+ query = parts.query + "&" + query
88
+
89
+ return urlunsplit((
90
+ parts.scheme,
91
+ parts.netloc,
92
+ parts.path,
93
+ query,
94
+ parts.fragment
95
+ ))
96
+
97
+
98
+ def request(
99
+ method: str,
100
+ url: str,
101
+ params: Optional[dict] = None,
102
+ headers: Optional[dict] = None,
103
+ data: Optional[Any] = None,
104
+ json: Optional[Any] = None,
105
+ timeout: float = 10.0,
106
+ verify=True,
107
+ ca_certs=None
108
+ ) -> HTTPResponse:
109
+ """
110
+ Perform an HTTP request.
111
+
112
+ Supported methods:
113
+ GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
114
+ """
115
+
116
+ if not isinstance(method, str):
117
+ raise TypeError("Method must be a string")
118
+
119
+ method = method.upper()
120
+
121
+ allowed_methods = {
122
+ "GET", "POST", "PUT", "PATCH",
123
+ "DELETE", "HEAD", "OPTIONS"
124
+ }
125
+
126
+ if method not in allowed_methods:
127
+ raise ValueError(f"Unsupported HTTP method: {method}")
128
+
129
+ if not isinstance(url, str):
130
+ raise TypeError("URL must be a string")
131
+
132
+ if not url.startswith(("http://", "https://")):
133
+ raise ValueError("URL must use HTTP or HTTPS")
134
+
135
+ if timeout <= 0:
136
+ raise ValueError("Timeout must be positive")
137
+
138
+ if data is not None and json is not None:
139
+ raise ValueError("Use either data or json, not both")
140
+
141
+ final_url = _append_params(url, params)
142
+
143
+ request_headers = dict(headers or {})
144
+ body = None
145
+
146
+ if json is not None:
147
+ body = json_module.dumps(json).encode("utf-8")
148
+
149
+ if not any(
150
+ key.lower() == "content-type"
151
+ for key in request_headers
152
+ ):
153
+ request_headers["Content-Type"] = "application/json"
154
+
155
+ elif data is not None:
156
+ if isinstance(data, str):
157
+ body = data.encode("utf-8")
158
+ elif isinstance(data, bytes):
159
+ body = data
160
+ else:
161
+ raise TypeError("data must be str or bytes")
162
+
163
+ req = urllib.request.Request(
164
+ final_url,
165
+ data=body,
166
+ headers=request_headers,
167
+ method=method
168
+ )
169
+
170
+ try:
171
+ if verify:
172
+ # Utilise certifi par défaut ou le certificat CA fourni.
173
+ context = ssl.create_default_context(
174
+ cafile=ca_certs or certifi.where()
175
+ )
176
+ else:
177
+ warnings.warn(
178
+ "SSL certificate verification is disabled. "
179
+ "This connection is insecure.",
180
+ RuntimeWarning,
181
+ stacklevel=2,
182
+ )
183
+ context = ssl._create_unverified_context()
184
+ with urllib.request.urlopen(
185
+ req,
186
+ timeout=timeout,
187
+ context=context
188
+ ) as response:
189
+
190
+ return HTTPResponse(
191
+ status_code=response.status,
192
+ url=response.geturl(),
193
+ headers=dict(response.headers.items()),
194
+ content=response.read()
195
+ )
196
+
197
+ except urllib.error.HTTPError as error:
198
+ # HTTP errors such as 404 and 500 are still valid responses.
199
+ return HTTPResponse(
200
+ status_code=error.code,
201
+ url=error.geturl(),
202
+ headers=dict(error.headers.items()),
203
+ content=error.read()
204
+ )
205
+
206
+ except urllib.error.URLError as error:
207
+ raise NetworkError(
208
+ f"Unable to reach {url}: {error.reason}"
209
+ ) from error
210
+
211
+
212
+ def get(url: str, **kwargs) -> HTTPResponse:
213
+ return request("GET", url, **kwargs)
214
+
215
+
216
+ def post(url: str, **kwargs) -> HTTPResponse:
217
+ return request("POST", url, **kwargs)
218
+
219
+
220
+ def put(url: str, **kwargs) -> HTTPResponse:
221
+ return request("PUT", url, **kwargs)
222
+
223
+
224
+ def patch(url: str, **kwargs) -> HTTPResponse:
225
+ return request("PATCH", url, **kwargs)
226
+
227
+
228
+ def delete(url: str, **kwargs) -> HTTPResponse:
229
+ return request("DELETE", url, **kwargs)
230
+
231
+
232
+ def head(url: str, **kwargs) -> HTTPResponse:
233
+ return request("HEAD", url, **kwargs)
234
+
235
+
236
+ def options(url: str, **kwargs) -> HTTPResponse:
237
+ return request("OPTIONS", url, **kwargs)
@@ -0,0 +1,100 @@
1
+ import socket
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ import psutil
7
+
8
+
9
+ @dataclass
10
+ class NetworkInterface:
11
+ name: str
12
+ ipv4: tuple[str, ...]
13
+ ipv6: tuple[str, ...]
14
+ mac: Optional[str]
15
+ is_up: bool
16
+
17
+
18
+ @dataclass
19
+ class DNSResult:
20
+ hostname: str
21
+ ipv4: tuple[str, ...]
22
+ ipv6: tuple[str, ...]
23
+
24
+
25
+ def interfaces() -> list[NetworkInterface]:
26
+ """Return all available network interfaces."""
27
+
28
+ addresses = psutil.net_if_addrs()
29
+ stats = psutil.net_if_stats()
30
+
31
+ results = []
32
+
33
+ for name, entries in addresses.items():
34
+ ipv4 = []
35
+ ipv6 = []
36
+ mac = None
37
+
38
+ for entry in entries:
39
+ if entry.family == socket.AF_INET:
40
+ ipv4.append(entry.address)
41
+
42
+ elif entry.family == socket.AF_INET6:
43
+ address = entry.address.split("%")[0]
44
+ ipv6.append(address)
45
+
46
+ elif entry.family == getattr(psutil, "AF_LINK", object()):
47
+ mac = entry.address
48
+
49
+ interface_stats = stats.get(name)
50
+
51
+ results.append(
52
+ NetworkInterface(
53
+ name=name,
54
+ ipv4=tuple(dict.fromkeys(ipv4)),
55
+ ipv6=tuple(dict.fromkeys(ipv6)),
56
+ mac=mac,
57
+ is_up=interface_stats.isup if interface_stats else False
58
+ )
59
+ )
60
+
61
+ return results
62
+
63
+
64
+ def resolve(host: str) -> DNSResult:
65
+ """Resolve a hostname into IPv4 and IPv6 addresses."""
66
+
67
+ if not isinstance(host, str) or not host.strip():
68
+ raise ValueError("Host must be a non-empty string")
69
+
70
+ addresses = socket.getaddrinfo(
71
+ host,
72
+ None,
73
+ socket.AF_UNSPEC,
74
+ socket.SOCK_STREAM
75
+ )
76
+
77
+ ipv4 = set()
78
+ ipv6 = set()
79
+
80
+ for result in addresses:
81
+ family = result[0]
82
+ address = result[4][0].split("%")[0]
83
+
84
+ if family == socket.AF_INET:
85
+ ipv4.add(address)
86
+
87
+ elif family == socket.AF_INET6:
88
+ ipv6.add(address)
89
+
90
+ return DNSResult(
91
+ hostname=host,
92
+ ipv4=tuple(sorted(ipv4)),
93
+ ipv6=tuple(sorted(ipv6))
94
+ )
95
+
96
+
97
+ def hostname() -> str:
98
+ """Return the local machine hostname."""
99
+
100
+ return socket.gethostname()