optforge 0.2.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.
- optforge/__init__.py +17 -0
- optforge/__main__.py +6 -0
- optforge/cli.py +56 -0
- optforge/core/__init__.py +24 -0
- optforge/core/cli.py +56 -0
- optforge/core/console.py +83 -0
- optforge/core/errors.py +28 -0
- optforge/core/formatting.py +26 -0
- optforge/core/net.py +87 -0
- optforge/core/processes.py +38 -0
- optforge/core/redaction.py +41 -0
- optforge/core/version.py +15 -0
- optforge/diskcheck/__init__.py +6 -0
- optforge/diskcheck/cli.py +113 -0
- optforge/diskcheck/models.py +31 -0
- optforge/diskcheck/service.py +56 -0
- optforge/envcheck/__init__.py +6 -0
- optforge/envcheck/cli.py +102 -0
- optforge/envcheck/models.py +33 -0
- optforge/envcheck/service.py +66 -0
- optforge/killNotif/cli.py +110 -0
- optforge/killNotif/core.py +24 -0
- optforge/killNotif/job.ps1 +52 -0
- optforge/portcheck/__init__.py +14 -0
- optforge/portcheck/binder.py +72 -0
- optforge/portcheck/cli.py +248 -0
- optforge/portcheck/connector.py +60 -0
- optforge/portcheck/models.py +117 -0
- optforge/portcheck/scanner.py +76 -0
- optforge/portcheck/service.py +109 -0
- optforge/portcheck/waiter.py +54 -0
- optforge/pycacheClean/__init__.py +6 -0
- optforge/pycacheClean/cleaner.py +43 -0
- optforge/pycacheClean/cli.py +127 -0
- optforge/pycacheClean/models.py +55 -0
- optforge/pycacheClean/scanner.py +122 -0
- optforge/pycacheClean/service.py +79 -0
- optforge/volume/__init__.py +7 -0
- optforge/volume/cli.py +224 -0
- optforge/volume/core.py +280 -0
- optforge-0.2.0.dist-info/METADATA +322 -0
- optforge-0.2.0.dist-info/RECORD +45 -0
- optforge-0.2.0.dist-info/WHEEL +4 -0
- optforge-0.2.0.dist-info/entry_points.txt +9 -0
- optforge-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Disk usage collection for a path or every mounted volume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
from optforge.core.errors import OperationFailed, OptForgeError
|
|
13
|
+
from optforge.diskcheck.models import DiskInfo
|
|
14
|
+
|
|
15
|
+
UsageReader = Callable[[str], tuple[int, int, int]]
|
|
16
|
+
PartitionReader = Callable[..., Iterable[Any]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiskService:
|
|
20
|
+
"""Read usage without crashing on one unreadable mount."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
usage: UsageReader = shutil.disk_usage,
|
|
25
|
+
partitions: PartitionReader = psutil.disk_partitions,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._usage = usage
|
|
28
|
+
self._partitions = partitions
|
|
29
|
+
|
|
30
|
+
def usage(self, path: str = ".") -> DiskInfo:
|
|
31
|
+
"""Return usage for ``path``."""
|
|
32
|
+
try:
|
|
33
|
+
total, used, free = self._usage(path)
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise OperationFailed(f"Could not read disk usage for {path!r}: {exc}") from exc
|
|
36
|
+
percent_used = (used / total * 100.0) if total else 0.0
|
|
37
|
+
return DiskInfo(
|
|
38
|
+
path=os.path.abspath(path),
|
|
39
|
+
total=int(total),
|
|
40
|
+
used=int(used),
|
|
41
|
+
free=int(free),
|
|
42
|
+
percent_used=float(percent_used),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def volumes(self) -> list[DiskInfo]:
|
|
46
|
+
"""Return usage for every mounted partition that can be read."""
|
|
47
|
+
results: list[DiskInfo] = []
|
|
48
|
+
for partition in self._partitions(all=True):
|
|
49
|
+
mountpoint = getattr(partition, "mountpoint", None)
|
|
50
|
+
if not mountpoint:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
results.append(self.usage(mountpoint))
|
|
54
|
+
except OptForgeError:
|
|
55
|
+
continue
|
|
56
|
+
return results
|
optforge/envcheck/cli.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""``envcheck``: inspect environment variables without leaking secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from optforge.core.cli import ErrorBoundary
|
|
11
|
+
from optforge.core.console import ConsoleIO
|
|
12
|
+
from optforge.core.version import get_version
|
|
13
|
+
from optforge.envcheck.models import EnvEntry, OutputFormat
|
|
14
|
+
from optforge.envcheck.service import EnvironmentService
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="envcheck",
|
|
18
|
+
help="Inspect environment variables and runtime configuration. Secrets are redacted by default.",
|
|
19
|
+
no_args_is_help=False,
|
|
20
|
+
rich_markup_mode="rich",
|
|
21
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
22
|
+
)
|
|
23
|
+
console = ConsoleIO()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _service() -> EnvironmentService:
|
|
27
|
+
return EnvironmentService()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
typer.echo(f"envcheck (optforge {get_version()})")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render_table(entries: list[EnvEntry]) -> None:
|
|
37
|
+
table = console.table(
|
|
38
|
+
"Environment variables",
|
|
39
|
+
[("Key", {"style": "cyan", "no_wrap": True}), ("Value", {})],
|
|
40
|
+
)
|
|
41
|
+
for entry in entries:
|
|
42
|
+
value = Text(entry.value, style="yellow" if entry.redacted else "")
|
|
43
|
+
table.add_row(console.cell(entry.key), value)
|
|
44
|
+
console.print(table)
|
|
45
|
+
if any(entry.redacted for entry in entries):
|
|
46
|
+
console.hint("Sensitive values are masked. Pass --show-secrets to reveal them.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback(invoke_without_command=True)
|
|
50
|
+
def main(
|
|
51
|
+
prefix: Annotated[
|
|
52
|
+
str | None,
|
|
53
|
+
typer.Option("--prefix", "-p", help="Only show variables starting with this prefix."),
|
|
54
|
+
] = None,
|
|
55
|
+
include_hidden: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--include-hidden/--no-include-hidden",
|
|
59
|
+
help="Include variables whose names begin with an underscore.",
|
|
60
|
+
),
|
|
61
|
+
] = True,
|
|
62
|
+
show_secrets: Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option("--show-secrets", help="Reveal values that look sensitive."),
|
|
65
|
+
] = False,
|
|
66
|
+
output: Annotated[
|
|
67
|
+
OutputFormat,
|
|
68
|
+
typer.Option("--format", "-f", case_sensitive=False, help="Output format."),
|
|
69
|
+
] = OutputFormat.table,
|
|
70
|
+
as_json: Annotated[
|
|
71
|
+
bool, typer.Option("--json", help="Alias for --format json.")
|
|
72
|
+
] = False,
|
|
73
|
+
version: Annotated[
|
|
74
|
+
bool | None,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--version",
|
|
77
|
+
"-V",
|
|
78
|
+
callback=_version_callback,
|
|
79
|
+
is_eager=True,
|
|
80
|
+
help="Show the version and exit.",
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""List environment variables, redacting secrets by default."""
|
|
85
|
+
with ErrorBoundary(console):
|
|
86
|
+
entries = _service().entries(
|
|
87
|
+
prefix=prefix,
|
|
88
|
+
include_hidden=include_hidden,
|
|
89
|
+
redact=not show_secrets,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
chosen = OutputFormat.json if as_json else output
|
|
93
|
+
if chosen is OutputFormat.json:
|
|
94
|
+
console.json([entry.to_dict() for entry in entries])
|
|
95
|
+
elif chosen is OutputFormat.dotenv:
|
|
96
|
+
console.text(_service().to_dotenv(entries))
|
|
97
|
+
else:
|
|
98
|
+
_render_table(entries)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
app()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Value objects for environment inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OutputFormat(str, Enum):
|
|
11
|
+
"""Supported ``envcheck`` output formats."""
|
|
12
|
+
|
|
13
|
+
table = "table"
|
|
14
|
+
json = "json"
|
|
15
|
+
dotenv = "dotenv"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class EnvEntry:
|
|
20
|
+
"""One environment variable, optionally redacted."""
|
|
21
|
+
|
|
22
|
+
key: str
|
|
23
|
+
value: str
|
|
24
|
+
sensitive: bool = False
|
|
25
|
+
redacted: bool = False
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"key": self.key,
|
|
30
|
+
"value": self.value,
|
|
31
|
+
"sensitive": self.sensitive,
|
|
32
|
+
"redacted": self.redacted,
|
|
33
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Environment variable collection and redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from optforge.core.redaction import SecretRedactor
|
|
9
|
+
from optforge.envcheck.models import EnvEntry
|
|
10
|
+
|
|
11
|
+
_QUOTE_TRIGGERS = frozenset(' \t\n#"\'')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EnvironmentService:
|
|
15
|
+
"""Read ``os.environ`` and decide what is safe to display."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
environ: Mapping[str, str] | None = None,
|
|
20
|
+
redactor: SecretRedactor | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._environ = environ if environ is not None else os.environ
|
|
23
|
+
self._redactor = redactor or SecretRedactor()
|
|
24
|
+
|
|
25
|
+
def entries(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
prefix: str | None = None,
|
|
29
|
+
include_hidden: bool = True,
|
|
30
|
+
redact: bool = True,
|
|
31
|
+
) -> list[EnvEntry]:
|
|
32
|
+
"""Return sorted entries, masking sensitive values by default."""
|
|
33
|
+
needle = prefix.lower() if prefix else None
|
|
34
|
+
entries: list[EnvEntry] = []
|
|
35
|
+
for key in sorted(self._environ):
|
|
36
|
+
if needle is not None and not key.lower().startswith(needle):
|
|
37
|
+
continue
|
|
38
|
+
if not include_hidden and key.startswith("_"):
|
|
39
|
+
continue
|
|
40
|
+
raw = self._environ[key]
|
|
41
|
+
sensitive = self._redactor.is_sensitive(key)
|
|
42
|
+
value = raw
|
|
43
|
+
redacted = False
|
|
44
|
+
if redact and sensitive:
|
|
45
|
+
value, redacted = self._redactor.redact(key, raw)
|
|
46
|
+
entries.append(
|
|
47
|
+
EnvEntry(key=key, value=value, sensitive=sensitive, redacted=redacted)
|
|
48
|
+
)
|
|
49
|
+
return entries
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def to_dotenv(entries: list[EnvEntry]) -> str:
|
|
53
|
+
"""Render entries as a ``.env`` fragment."""
|
|
54
|
+
return "\n".join(_dotenv_line(entry) for entry in entries)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dotenv_line(entry: EnvEntry) -> str:
|
|
58
|
+
if not entry.value or not (set(entry.value) & _QUOTE_TRIGGERS):
|
|
59
|
+
return f"{entry.key}={entry.value}"
|
|
60
|
+
escaped = (
|
|
61
|
+
entry.value.replace("\\", "\\\\")
|
|
62
|
+
.replace('"', '\\"')
|
|
63
|
+
.replace("\n", "\\n")
|
|
64
|
+
.replace("\t", "\\t")
|
|
65
|
+
)
|
|
66
|
+
return f'{entry.key}="{escaped}"'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""``killNotif``: clear Windows toast notifications.
|
|
2
|
+
|
|
3
|
+
Exit codes
|
|
4
|
+
0 the command ran successfully (even when nothing needed clearing)
|
|
5
|
+
2 invalid usage (raised by Typer)
|
|
6
|
+
3 the operation could not run (wrong OS, missing helper, PowerShell error)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from optforge.core.cli import ErrorBoundary
|
|
18
|
+
from optforge.core.console import ConsoleIO
|
|
19
|
+
from optforge.core.errors import OperationFailed
|
|
20
|
+
from optforge.core.version import get_version
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="killNotif",
|
|
24
|
+
help="Clear Windows toast notifications, optionally for a single app.",
|
|
25
|
+
epilog="Exit codes: 0 ok | 2 bad usage | 3 could not run",
|
|
26
|
+
no_args_is_help=False,
|
|
27
|
+
rich_markup_mode="rich",
|
|
28
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
29
|
+
)
|
|
30
|
+
console = ConsoleIO()
|
|
31
|
+
|
|
32
|
+
AumidOpt = Annotated[
|
|
33
|
+
str | None,
|
|
34
|
+
typer.Option(
|
|
35
|
+
"--aumid",
|
|
36
|
+
"-a",
|
|
37
|
+
metavar="AUMID",
|
|
38
|
+
help="Only clear notifications from this AppUserModelID.",
|
|
39
|
+
),
|
|
40
|
+
]
|
|
41
|
+
QuietOpt = Annotated[
|
|
42
|
+
bool, typer.Option("--quiet", "-q", help="Print nothing; use the exit code only.")
|
|
43
|
+
]
|
|
44
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Print machine-readable JSON.")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _version_callback(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
typer.echo(f"killNotif (optforge {get_version()})")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _clear(aumid: str | None) -> int:
|
|
54
|
+
"""Import lazily so ``--help`` and ``--version`` stay fast."""
|
|
55
|
+
from optforge.killNotif.core import clear_notifications
|
|
56
|
+
|
|
57
|
+
return clear_notifications(aumid)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run(aumid: str | None) -> int:
|
|
61
|
+
if sys.platform != "win32":
|
|
62
|
+
raise OperationFailed("killNotif requires Windows 10/11 (WinRT toast history).")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
return _clear(aumid)
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
raise OperationFailed(f"{exc} Ensure PowerShell 5.1+ is installed.") from exc
|
|
68
|
+
except RuntimeError as exc:
|
|
69
|
+
raise OperationFailed(str(exc)) from exc
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise OperationFailed(f"unexpected helper output: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def main(
|
|
76
|
+
aumid: AumidOpt = None,
|
|
77
|
+
json_output: JsonOpt = False,
|
|
78
|
+
quiet: QuietOpt = False,
|
|
79
|
+
version: Annotated[
|
|
80
|
+
bool | None,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--version",
|
|
83
|
+
"-V",
|
|
84
|
+
callback=_version_callback,
|
|
85
|
+
is_eager=True,
|
|
86
|
+
help="Show the version and exit.",
|
|
87
|
+
),
|
|
88
|
+
] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Clear toast notifications from the Windows Action Center."""
|
|
91
|
+
target = aumid.strip() if aumid else None
|
|
92
|
+
|
|
93
|
+
with ErrorBoundary(console):
|
|
94
|
+
cleared = _run(target)
|
|
95
|
+
|
|
96
|
+
if quiet:
|
|
97
|
+
return
|
|
98
|
+
if json_output:
|
|
99
|
+
console.json({"aumid": target, "cleared": cleared})
|
|
100
|
+
return
|
|
101
|
+
if cleared == 0:
|
|
102
|
+
console.print(Text("No notifications to clear.", style="dim"))
|
|
103
|
+
elif target:
|
|
104
|
+
console.print(Text(f"Cleared {cleared} sender(s) matching {target}.", style="green"))
|
|
105
|
+
else:
|
|
106
|
+
console.print(Text(f"Cleared {cleared} sender(s).", style="green"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
app()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
JOB = Path(__file__).with_name("job.ps1")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clear_notifications(aumid: str | None = None) -> int:
|
|
8
|
+
"""Clear all notifications (or one sender's). Returns senders cleared."""
|
|
9
|
+
if not JOB.exists():
|
|
10
|
+
raise FileNotFoundError(f"helper script missing: {JOB}")
|
|
11
|
+
|
|
12
|
+
cmd = [
|
|
13
|
+
"powershell", "-NoProfile", "-NonInteractive",
|
|
14
|
+
"-ExecutionPolicy", "Bypass", "-File", str(JOB),
|
|
15
|
+
]
|
|
16
|
+
if aumid:
|
|
17
|
+
cmd += ["-Aumid", aumid]
|
|
18
|
+
|
|
19
|
+
res = subprocess.run(cmd, capture_output=True, text=True)
|
|
20
|
+
if res.returncode != 0:
|
|
21
|
+
raise RuntimeError((res.stderr or res.stdout).strip() or "job.ps1 failed")
|
|
22
|
+
|
|
23
|
+
out = res.stdout.strip().splitlines()[-1] if res.stdout.strip() else "0"
|
|
24
|
+
return int(out)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#Requires -Version 5.1
|
|
2
|
+
[CmdletBinding()]
|
|
3
|
+
param(
|
|
4
|
+
[string]$Aumid # optional: clear only this sender; empty = all
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null
|
|
11
|
+
} catch {
|
|
12
|
+
Write-Error "WinRT notification API unavailable (not Windows 10/11?)"
|
|
13
|
+
exit 2
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
$history = [Windows.UI.Notifications.ToastNotificationManager]::History
|
|
17
|
+
|
|
18
|
+
if ($Aumid) {
|
|
19
|
+
$targets = @($Aumid)
|
|
20
|
+
} else {
|
|
21
|
+
$key = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings'
|
|
22
|
+
if (-not (Test-Path $key)) {
|
|
23
|
+
Write-Output 0
|
|
24
|
+
exit 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
$targets = @(
|
|
28
|
+
Get-ChildItem -Path $key -ErrorAction Stop |
|
|
29
|
+
ForEach-Object { $_.PSChildName }
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$cleared = 0
|
|
34
|
+
foreach ($target in $targets) {
|
|
35
|
+
if ([string]::IsNullOrWhiteSpace($target)) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
$items = $history.GetHistory($target)
|
|
41
|
+
if (@($items).Count -eq 0) {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
$history.Clear($target)
|
|
46
|
+
$cleared++
|
|
47
|
+
} catch {
|
|
48
|
+
Write-Warning "Could not clear notifications for '$target': $($_.Exception.Message)"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Write-Output $cleared
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Inspect listening TCP ports and test connectivity."""
|
|
2
|
+
|
|
3
|
+
from optforge.portcheck.models import CheckResult, ConnectResult, Listener, PortState, SortKey, WaitResult
|
|
4
|
+
from optforge.portcheck.service import PortService
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CheckResult",
|
|
8
|
+
"ConnectResult",
|
|
9
|
+
"Listener",
|
|
10
|
+
"PortService",
|
|
11
|
+
"PortState",
|
|
12
|
+
"SortKey",
|
|
13
|
+
"WaitResult",
|
|
14
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Bind probes that answer "can this host:port be bound right now?"."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
from optforge.core.errors import OperationFailed
|
|
9
|
+
from optforge.core.net import Endpoint, resolve_endpoints
|
|
10
|
+
|
|
11
|
+
_WILDCARD_HOSTS = frozenset({"", "*", "any", "all"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PortBinder:
|
|
15
|
+
"""Test bindability without leaving a socket behind.
|
|
16
|
+
|
|
17
|
+
Notes:
|
|
18
|
+
* POSIX sets ``SO_REUSEADDR`` so a socket in ``TIME_WAIT`` does not make
|
|
19
|
+
a genuinely free port look busy.
|
|
20
|
+
* Windows sets ``SO_EXCLUSIVEADDRUSE`` instead; ``SO_REUSEADDR`` on
|
|
21
|
+
Windows lets two processes bind the same port, which is exactly the
|
|
22
|
+
bug this tool exists to detect.
|
|
23
|
+
* A successful probe is inherently racy: another process may take the
|
|
24
|
+
port after the probe closes. Callers must treat the answer as a
|
|
25
|
+
snapshot.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, default_host: str = "0.0.0.0") -> None:
|
|
29
|
+
self._default_host = default_host
|
|
30
|
+
|
|
31
|
+
def is_free(self, port: int, host: str | None = None) -> bool:
|
|
32
|
+
"""Return ``True`` only if every resolved address for the host binds."""
|
|
33
|
+
endpoints = self._endpoints(port, host or self._default_host)
|
|
34
|
+
if not endpoints:
|
|
35
|
+
return True
|
|
36
|
+
return all(self._can_bind(endpoint) for endpoint in endpoints)
|
|
37
|
+
|
|
38
|
+
def is_free_on_all_interfaces(self, port: int) -> bool:
|
|
39
|
+
"""Return ``True`` if both the IPv4 and IPv6 wildcard addresses bind."""
|
|
40
|
+
return self.is_free(port, "0.0.0.0") and self.is_free(port, "::")
|
|
41
|
+
|
|
42
|
+
def _endpoints(self, port: int, host: str) -> list[Endpoint]:
|
|
43
|
+
target = None if host.lower() in _WILDCARD_HOSTS else host
|
|
44
|
+
try:
|
|
45
|
+
return resolve_endpoints(target or "", port, passive=True)
|
|
46
|
+
except OperationFailed:
|
|
47
|
+
# No address of this family exists on the machine (for example IPv6
|
|
48
|
+
# is disabled). There is nothing to bind, so it cannot be occupied.
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
def _can_bind(self, endpoint: Endpoint) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
sock = socket.socket(endpoint.family, socket.SOCK_STREAM)
|
|
54
|
+
except OSError:
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
self._configure(sock)
|
|
58
|
+
sock.bind(endpoint.sockaddr)
|
|
59
|
+
return True
|
|
60
|
+
except OSError:
|
|
61
|
+
return False
|
|
62
|
+
finally:
|
|
63
|
+
sock.close()
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _configure(sock: socket.socket) -> None:
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
|
|
69
|
+
if exclusive is not None:
|
|
70
|
+
sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
|
|
71
|
+
return
|
|
72
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|