netforge-network-toolkit 0.1.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.
netforge/__init__.py ADDED
@@ -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
+ )
netforge/http.py ADDED
@@ -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)
netforge/network.py ADDED
@@ -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()
netforge/ports.py ADDED
@@ -0,0 +1,152 @@
1
+
2
+ """
3
+ NetForge - TCP Port Scanner
4
+
5
+ Provides tools to inspect TCP ports on a remote or local host.
6
+ """
7
+
8
+ import socket
9
+ import time
10
+
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from dataclasses import dataclass
13
+ from typing import Iterable
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PortResult:
18
+ host: str
19
+ port: int
20
+ status: str
21
+ latency_ms: float | None = None
22
+ error: str | None = None
23
+
24
+ @property
25
+ def is_open(self) -> bool:
26
+ return self.status == "open"
27
+
28
+
29
+ def _validate_port(port: int) -> None:
30
+ if isinstance(port, bool) or not isinstance(port, int):
31
+ raise TypeError("Port must be an integer")
32
+
33
+ if not 1 <= port <= 65535:
34
+ raise ValueError("Port must be between 1 and 65535")
35
+
36
+
37
+ def check_port(
38
+ host: str,
39
+ port: int,
40
+ timeout: float = 1.0,
41
+ ) -> PortResult:
42
+ """
43
+ Check whether a TCP port is open.
44
+
45
+ Args:
46
+ host: Hostname or IP address.
47
+ port: TCP port number (1-65535).
48
+ timeout: Maximum connection time in seconds.
49
+
50
+ Returns:
51
+ A PortResult containing the port status.
52
+ """
53
+
54
+ if not isinstance(host, str) or not host.strip():
55
+ raise ValueError("Host must be a non-empty string")
56
+
57
+ _validate_port(port)
58
+
59
+ if timeout <= 0:
60
+ raise ValueError("Timeout must be greater than zero")
61
+
62
+ start = time.perf_counter()
63
+
64
+ try:
65
+ with socket.create_connection(
66
+ (host, port),
67
+ timeout=timeout,
68
+ ):
69
+ latency = (time.perf_counter() - start) * 1000
70
+
71
+ return PortResult(
72
+ host=host,
73
+ port=port,
74
+ status="open",
75
+ latency_ms=round(latency, 2),
76
+ )
77
+
78
+ except socket.timeout as error:
79
+ return PortResult(
80
+ host=host,
81
+ port=port,
82
+ status="filtered",
83
+ error=str(error) or "Connection timed out",
84
+ )
85
+
86
+ except ConnectionRefusedError as error:
87
+ return PortResult(
88
+ host=host,
89
+ port=port,
90
+ status="closed",
91
+ error=str(error),
92
+ )
93
+
94
+ except OSError as error:
95
+ return PortResult(
96
+ host=host,
97
+ port=port,
98
+ status="error",
99
+ error=str(error),
100
+ )
101
+
102
+
103
+ def scan_ports(
104
+ host: str,
105
+ ports: Iterable[int],
106
+ timeout: float = 1.0,
107
+ max_workers: int = 50,
108
+ ) -> list[PortResult]:
109
+ """
110
+ Scan multiple TCP ports concurrently.
111
+
112
+ Args:
113
+ host: Hostname or IP address.
114
+ ports: Iterable of TCP port numbers.
115
+ timeout: Timeout for each connection.
116
+ max_workers: Maximum number of concurrent workers.
117
+
118
+ Returns:
119
+ A list of PortResult objects, sorted by port number.
120
+ """
121
+
122
+ if not isinstance(host, str) or not host.strip():
123
+ raise ValueError("Host must be a non-empty string")
124
+
125
+ if timeout <= 0:
126
+ raise ValueError("Timeout must be greater than zero")
127
+
128
+ if (
129
+ isinstance(max_workers, bool)
130
+ or not isinstance(max_workers, int)
131
+ or max_workers < 1
132
+ ):
133
+ raise ValueError("max_workers must be a positive integer")
134
+
135
+ port_list = list(ports)
136
+
137
+ for port in port_list:
138
+ _validate_port(port)
139
+
140
+ # Remove duplicates and sort ports.
141
+ port_list = sorted(set(port_list))
142
+
143
+ if not port_list:
144
+ return []
145
+
146
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
147
+ results = executor.map(
148
+ lambda port: check_port(host, port, timeout),
149
+ port_list,
150
+ )
151
+
152
+ return list(results)
netforge/servers.py ADDED
@@ -0,0 +1,336 @@
1
+
2
+ """
3
+ NetForge - HTTP Server
4
+
5
+ A small multithreaded HTTP server with route handling.
6
+ """
7
+
8
+ import json
9
+ import threading
10
+
11
+ from dataclasses import dataclass, field
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from typing import Callable
14
+ from urllib.parse import parse_qs, urlsplit
15
+
16
+
17
+ @dataclass
18
+ class ServerRequest:
19
+ method: str
20
+ path: str
21
+ query: dict[str, list[str]]
22
+ headers: dict[str, str]
23
+ body: bytes
24
+
25
+ @property
26
+ def text(self) -> str:
27
+ return self.body.decode("utf-8", errors="replace")
28
+
29
+ def json(self):
30
+ return json.loads(self.body.decode("utf-8"))
31
+
32
+
33
+ @dataclass
34
+ class ServerResponse:
35
+ body: bytes = b""
36
+ status: int = 200
37
+ headers: dict[str, str] = field(default_factory=dict)
38
+
39
+ @classmethod
40
+ def text(
41
+ cls,
42
+ content: str,
43
+ status: int = 200,
44
+ headers: dict[str, str] | None = None,
45
+ ):
46
+ response_headers = {
47
+ "Content-Type": "text/plain; charset=utf-8"
48
+ }
49
+ if headers:
50
+ response_headers.update(headers)
51
+
52
+ return cls(
53
+ body=content.encode("utf-8"),
54
+ status=status,
55
+ headers=response_headers,
56
+ )
57
+
58
+ @classmethod
59
+ def json(
60
+ cls,
61
+ data,
62
+ status: int = 200,
63
+ headers: dict[str, str] | None = None,
64
+ ):
65
+ response_headers = {
66
+ "Content-Type": "application/json; charset=utf-8"
67
+ }
68
+ if headers:
69
+ response_headers.update(headers)
70
+
71
+ return cls(
72
+ body=json.dumps(data, ensure_ascii=False).encode("utf-8"),
73
+ status=status,
74
+ headers=response_headers,
75
+ )
76
+
77
+
78
+ RouteHandler = Callable[[ServerRequest], ServerResponse]
79
+
80
+
81
+ class HTTPServer:
82
+ """
83
+ Multithreaded HTTP server with simple route registration.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ host: str = "127.0.0.1",
89
+ port: int = 8000,
90
+ max_body_size: int = 1_048_576,
91
+ ):
92
+ if not isinstance(host, str) or not host.strip():
93
+ raise ValueError("Host must be a non-empty string")
94
+
95
+ if (
96
+ isinstance(port, bool)
97
+ or not isinstance(port, int)
98
+ or not 0 <= port <= 65535
99
+ ):
100
+ raise ValueError("Port must be between 0 and 65535")
101
+
102
+ if (
103
+ isinstance(max_body_size, bool)
104
+ or not isinstance(max_body_size, int)
105
+ or max_body_size < 0
106
+ ):
107
+ raise ValueError("max_body_size must be a non-negative integer")
108
+
109
+ self.host = host
110
+ self.port = port
111
+ self.max_body_size = max_body_size
112
+
113
+ self._routes: dict[tuple[str, str], RouteHandler] = {}
114
+ self._httpd = None
115
+ self._thread = None
116
+ self._lock = threading.RLock()
117
+
118
+ def route(self, method: str, path: str):
119
+ """
120
+ Register a handler for an HTTP method and path.
121
+
122
+ Example:
123
+ @server.route("GET", "/")
124
+ def home(request):
125
+ return ServerResponse.text("Hello!")
126
+ """
127
+
128
+ method = method.upper()
129
+
130
+ if not method.isalpha():
131
+ raise ValueError("Invalid HTTP method")
132
+
133
+ if not isinstance(path, str) or not path.startswith("/"):
134
+ raise ValueError("Route path must start with '/'")
135
+
136
+ def decorator(handler: RouteHandler):
137
+ if not callable(handler):
138
+ raise TypeError("Handler must be callable")
139
+
140
+ key = (method, path)
141
+
142
+ with self._lock:
143
+ if key in self._routes:
144
+ raise ValueError(
145
+ f"Route already registered: {method} {path}"
146
+ )
147
+
148
+ self._routes[key] = handler
149
+
150
+ return handler
151
+
152
+ return decorator
153
+
154
+ def _make_handler(self):
155
+ server = self
156
+
157
+ class RequestHandler(BaseHTTPRequestHandler):
158
+
159
+ def _handle(self):
160
+ parsed = urlsplit(self.path)
161
+ path = parsed.path
162
+ method = self.command.upper()
163
+
164
+ length_header = self.headers.get("Content-Length", "0")
165
+
166
+ try:
167
+ length = int(length_header)
168
+ except ValueError:
169
+ self._send_response(
170
+ ServerResponse.text("Invalid Content-Length", 400)
171
+ )
172
+ return
173
+
174
+ if length < 0:
175
+ self._send_response(
176
+ ServerResponse.text("Invalid Content-Length", 400)
177
+ )
178
+ return
179
+
180
+ if length > server.max_body_size:
181
+ self._send_response(
182
+ ServerResponse.text("Request body too large", 413)
183
+ )
184
+ return
185
+
186
+ body = self.rfile.read(length) if length else b""
187
+
188
+ request = ServerRequest(
189
+ method=method,
190
+ path=path,
191
+ query=parse_qs(parsed.query),
192
+ headers=dict(self.headers.items()),
193
+ body=body,
194
+ )
195
+
196
+ with server._lock:
197
+ handler = server._routes.get((method, path))
198
+
199
+ if handler is None:
200
+ response = ServerResponse.json(
201
+ {"error": "Not Found", "path": path},
202
+ status=404,
203
+ )
204
+ else:
205
+ try:
206
+ response = handler(request)
207
+
208
+ if not isinstance(response, ServerResponse):
209
+ raise TypeError(
210
+ "Route handlers must return ServerResponse"
211
+ )
212
+
213
+ except Exception:
214
+ response = ServerResponse.json(
215
+ {"error": "Internal Server Error"},
216
+ status=500,
217
+ )
218
+
219
+ self._send_response(response)
220
+
221
+ def _send_response(self, response: ServerResponse):
222
+ self.send_response(response.status)
223
+
224
+ headers = dict(response.headers)
225
+ headers.setdefault("Content-Length", str(len(response.body)))
226
+
227
+ for name, value in headers.items():
228
+ self.send_header(name, value)
229
+
230
+ self.end_headers()
231
+
232
+ if self.command != "HEAD" and response.body:
233
+ self.wfile.write(response.body)
234
+
235
+ def do_GET(self):
236
+ self._handle()
237
+
238
+ def do_POST(self):
239
+ self._handle()
240
+
241
+ def do_PUT(self):
242
+ self._handle()
243
+
244
+ def do_PATCH(self):
245
+ self._handle()
246
+
247
+ def do_DELETE(self):
248
+ self._handle()
249
+
250
+ def do_HEAD(self):
251
+ self._handle()
252
+
253
+ def do_OPTIONS(self):
254
+ self._handle()
255
+
256
+ def log_message(self, format, *args):
257
+ # Keep the console clean by default.
258
+ pass
259
+
260
+ return RequestHandler
261
+
262
+ @property
263
+ def address(self) -> tuple[str, int]:
264
+ if self._httpd is None:
265
+ return self.host, self.port
266
+
267
+ host, port = self._httpd.server_address[:2]
268
+ return host, port
269
+
270
+ @property
271
+ def is_running(self) -> bool:
272
+ return (
273
+ self._thread is not None
274
+ and self._thread.is_alive()
275
+ )
276
+
277
+ def serve_forever(self):
278
+ """Start the server in the current thread."""
279
+
280
+ if self._httpd is not None:
281
+ raise RuntimeError("Server has already been started")
282
+
283
+ self._httpd = ThreadingHTTPServer(
284
+ (self.host, self.port),
285
+ self._make_handler(),
286
+ )
287
+
288
+ try:
289
+ self._httpd.serve_forever()
290
+ finally:
291
+ self._httpd.server_close()
292
+ self._httpd = None
293
+
294
+ def start(self):
295
+ """Start the server in a background thread."""
296
+
297
+ if self._httpd is not None:
298
+ raise RuntimeError("Server has already been started")
299
+
300
+ self._httpd = ThreadingHTTPServer(
301
+ (self.host, self.port),
302
+ self._make_handler(),
303
+ )
304
+
305
+ self._thread = threading.Thread(
306
+ target=self._httpd.serve_forever,
307
+ daemon=True,
308
+ )
309
+ self._thread.start()
310
+
311
+ return self
312
+
313
+ def shutdown(self):
314
+ """Stop the server."""
315
+
316
+ if self._httpd is None:
317
+ return
318
+
319
+ httpd = self._httpd
320
+ httpd.shutdown()
321
+ httpd.server_close()
322
+
323
+ if self._thread is not None:
324
+ self._thread.join(timeout=5)
325
+
326
+ self._httpd = None
327
+ self._thread = None
328
+
329
+ def close(self):
330
+ self.shutdown()
331
+
332
+ def __enter__(self):
333
+ return self.start()
334
+
335
+ def __exit__(self, exc_type, exc_value, traceback):
336
+ self.shutdown()
netforge/sockets.py ADDED
@@ -0,0 +1,197 @@
1
+
2
+ import socket
3
+
4
+ from typing import Optional
5
+
6
+
7
+ class SocketConnection:
8
+ """Manage a TCP or UDP socket connection."""
9
+
10
+ def __init__(self, sock: socket.socket, protocol: str):
11
+ self._socket = sock
12
+ self.protocol = protocol.lower()
13
+ self.closed = False
14
+
15
+ @property
16
+ def address(self):
17
+ """Return the remote address."""
18
+ return self._socket.getpeername()
19
+
20
+ def send(self, data: bytes) -> int:
21
+ """Send bytes through the connection."""
22
+
23
+ if self.closed:
24
+ raise RuntimeError("Socket is closed")
25
+
26
+ if not isinstance(data, bytes):
27
+ raise TypeError("Data must be bytes")
28
+
29
+ if self.protocol == "tcp":
30
+ self._socket.sendall(data)
31
+ return len(data)
32
+
33
+ return self._socket.send(data)
34
+
35
+ def receive(self, max_bytes: int = 4096) -> bytes:
36
+ """Receive bytes from the connection."""
37
+
38
+ if self.closed:
39
+ raise RuntimeError("Socket is closed")
40
+
41
+ if max_bytes <= 0:
42
+ raise ValueError("max_bytes must be positive")
43
+
44
+ return self._socket.recv(max_bytes)
45
+
46
+ def close(self):
47
+ """Close the connection."""
48
+
49
+ if not self.closed:
50
+ self._socket.close()
51
+ self.closed = True
52
+
53
+ def __enter__(self):
54
+ return self
55
+
56
+ def __exit__(self, exc_type, exc_value, traceback):
57
+ self.close()
58
+
59
+
60
+ class TCPServer:
61
+ """A basic TCP server."""
62
+
63
+ def __init__(
64
+ self,
65
+ host: str,
66
+ port: int,
67
+ backlog: int = 5,
68
+ timeout: Optional[float] = None
69
+ ):
70
+ if not 0 <= port <= 65535:
71
+ raise ValueError("Invalid port")
72
+
73
+ if backlog <= 0:
74
+ raise ValueError("backlog must be positive")
75
+
76
+ self._socket = socket.socket(
77
+ socket.AF_INET,
78
+ socket.SOCK_STREAM
79
+ )
80
+
81
+ self._socket.setsockopt(
82
+ socket.SOL_SOCKET,
83
+ socket.SO_REUSEADDR,
84
+ 1
85
+ )
86
+
87
+ self._socket.settimeout(timeout)
88
+
89
+ self._socket.bind((host, port))
90
+ self._socket.listen(backlog)
91
+
92
+ self.closed = False
93
+
94
+ @property
95
+ def address(self):
96
+ """Return the server address."""
97
+ return self._socket.getsockname()
98
+
99
+ def accept(self):
100
+ """Accept an incoming TCP connection."""
101
+
102
+ if self.closed:
103
+ raise RuntimeError("Server is closed")
104
+
105
+ client, address = self._socket.accept()
106
+
107
+ return SocketConnection(client, "tcp"), address
108
+
109
+ def close(self):
110
+ """Stop the server."""
111
+
112
+ if not self.closed:
113
+ self._socket.close()
114
+ self.closed = True
115
+
116
+ def __enter__(self):
117
+ return self
118
+
119
+ def __exit__(self, exc_type, exc_value, traceback):
120
+ self.close()
121
+
122
+
123
+ def connect(
124
+ host: str,
125
+ port: int,
126
+ protocol: str = "tcp",
127
+ timeout: Optional[float] = 5.0
128
+ ) -> SocketConnection:
129
+ """
130
+ Connect to a remote host using TCP or UDP.
131
+ """
132
+
133
+ protocol = protocol.lower()
134
+
135
+ if protocol not in ("tcp", "udp"):
136
+ raise ValueError("Protocol must be 'tcp' or 'udp'")
137
+
138
+ if not isinstance(host, str) or not host.strip():
139
+ raise ValueError("Host must be a non-empty string")
140
+
141
+ if not isinstance(port, int) or not 1 <= port <= 65535:
142
+ raise ValueError("Port must be between 1 and 65535")
143
+
144
+ if timeout is not None and timeout < 0:
145
+ raise ValueError("Timeout cannot be negative")
146
+
147
+ if protocol == "tcp":
148
+ sock = socket.create_connection(
149
+ (host, port),
150
+ timeout=timeout
151
+ )
152
+
153
+ else:
154
+ addresses = socket.getaddrinfo(
155
+ host,
156
+ port,
157
+ type=socket.SOCK_DGRAM
158
+ )
159
+
160
+ last_error = None
161
+ sock = None
162
+
163
+ for family, socktype, proto, _, address in addresses:
164
+ candidate = socket.socket(family, socktype, proto)
165
+ candidate.settimeout(timeout)
166
+
167
+ try:
168
+ candidate.connect(address)
169
+ sock = candidate
170
+ break
171
+
172
+ except OSError as error:
173
+ last_error = error
174
+ candidate.close()
175
+
176
+ if sock is None:
177
+ if last_error:
178
+ raise last_error
179
+ raise OSError("Unable to establish UDP connection")
180
+
181
+ return SocketConnection(sock, protocol)
182
+
183
+
184
+ def listen(
185
+ host: str = "127.0.0.1",
186
+ port: int = 5000,
187
+ backlog: int = 5,
188
+ timeout: Optional[float] = None
189
+ ) -> TCPServer:
190
+ """Create and start a TCP server."""
191
+
192
+ return TCPServer(
193
+ host=host,
194
+ port=port,
195
+ backlog=backlog,
196
+ timeout=timeout
197
+ )
netforge/system.py ADDED
@@ -0,0 +1,26 @@
1
+ import platform
2
+ import socket
3
+ import sys
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class SystemInfo:
10
+ hostname: str
11
+ os: str
12
+ os_version: str
13
+ architecture: str
14
+ python_version: str
15
+
16
+
17
+ def info() -> SystemInfo:
18
+ """Return information about the current machine."""
19
+
20
+ return SystemInfo(
21
+ hostname=socket.gethostname(),
22
+ os=platform.system(),
23
+ os_version=platform.release(),
24
+ architecture=platform.machine(),
25
+ python_version=sys.version.split()[0]
26
+ )
@@ -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,12 @@
1
+ netforge/__init__.py,sha256=upOPi5VSejzhO55m59CMYm30Xv8esb_qJ04X6RIFFQY,310
2
+ netforge/diagnostics.py,sha256=W8Sc0ODC1P27PrK-OvrqKuRthp5DmDXM8e26bBmDrM4,4311
3
+ netforge/http.py,sha256=shpMm9ngtPzBnunhGflQK-OlpmWapPtDtdOhYxJQLHc,6199
4
+ netforge/network.py,sha256=hvjioZeIEOVLk-XLGssBpHhe42c5bFU1KBiCIYu8MN0,2291
5
+ netforge/ports.py,sha256=Wkq5_BNGDO_zcvcA7c9hisq9lDksG9IRk-eXl1SiKQo,3685
6
+ netforge/servers.py,sha256=Nw-Cmp4ozgav6U72B1O_OxbYqL8BIE_Cp2OzbDYBXj8,9251
7
+ netforge/sockets.py,sha256=JyNcQ_cROEvaEPhg0AaphD51bP3FCbHmxaBG3NFSxKw,4820
8
+ netforge/system.py,sha256=EXLuFqAEaeG54HVVnK5Zl_sMAQTKDQ2--93_ZPcUaGo,536
9
+ netforge_network_toolkit-0.1.0.dist-info/METADATA,sha256=THeg4W35wjTTAH6lmlZwuAFMbJFDh-CwYwyP-lEhh4Y,557
10
+ netforge_network_toolkit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ netforge_network_toolkit-0.1.0.dist-info/top_level.txt,sha256=ggqGwPJot8thNMrgeLIp-SpweSr4-uPNagHyUJwPrbw,9
12
+ netforge_network_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ netforge