sootool 0.1.1__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.
- sootool/__init__.py +11 -0
- sootool/__main__.py +245 -0
- sootool/core/__init__.py +0 -0
- sootool/core/audit.py +42 -0
- sootool/core/batch.py +129 -0
- sootool/core/calc.py +748 -0
- sootool/core/cast.py +99 -0
- sootool/core/decimal_ops.py +44 -0
- sootool/core/determinism.py +63 -0
- sootool/core/errors.py +68 -0
- sootool/core/locale_kr.py +157 -0
- sootool/core/pipeline.py +295 -0
- sootool/core/registry.py +86 -0
- sootool/core/rounding.py +38 -0
- sootool/core/types.py +47 -0
- sootool/core/units.py +32 -0
- sootool/middleware/__init__.py +5 -0
- sootool/middleware/auth.py +55 -0
- sootool/middleware/cors.py +23 -0
- sootool/middleware/locale.py +62 -0
- sootool/middleware/logging.py +56 -0
- sootool/middleware/request_id.py +25 -0
- sootool/modules/__init__.py +0 -0
- sootool/modules/accounting/README.md +37 -0
- sootool/modules/accounting/__init__.py +25 -0
- sootool/modules/accounting/bookkeeping.py +52 -0
- sootool/modules/accounting/cashflow.py +99 -0
- sootool/modules/accounting/depreciation.py +277 -0
- sootool/modules/accounting/dupont.py +151 -0
- sootool/modules/accounting/income_statement.py +100 -0
- sootool/modules/accounting/ratios.py +110 -0
- sootool/modules/accounting/vat.py +137 -0
- sootool/modules/crypto/README.md +59 -0
- sootool/modules/crypto/__init__.py +9 -0
- sootool/modules/crypto/advanced.py +255 -0
- sootool/modules/crypto/arithmetic.py +167 -0
- sootool/modules/crypto/hash_ops.py +56 -0
- sootool/modules/crypto/primes.py +125 -0
- sootool/modules/datetime_/README.md +42 -0
- sootool/modules/datetime_/__init__.py +16 -0
- sootool/modules/datetime_/age.py +136 -0
- sootool/modules/datetime_/business_days.py +145 -0
- sootool/modules/datetime_/day_count.py +138 -0
- sootool/modules/datetime_/fiscal.py +264 -0
- sootool/modules/datetime_/lunar.py +440 -0
- sootool/modules/datetime_/timezone_ops.py +83 -0
- sootool/modules/engineering/README.md +93 -0
- sootool/modules/engineering/__init__.py +91 -0
- sootool/modules/engineering/control.py +325 -0
- sootool/modules/engineering/electrical.py +316 -0
- sootool/modules/engineering/electrical_ac.py +830 -0
- sootool/modules/engineering/equivalent_circuit.py +143 -0
- sootool/modules/engineering/fluid.py +555 -0
- sootool/modules/engineering/gear_bearing.py +230 -0
- sootool/modules/engineering/materials.py +264 -0
- sootool/modules/engineering/mechanical.py +313 -0
- sootool/modules/engineering/reliability.py +234 -0
- sootool/modules/engineering/si_prefix.py +106 -0
- sootool/modules/engineering/structural.py +398 -0
- sootool/modules/engineering/thermal.py +306 -0
- sootool/modules/finance/README.md +76 -0
- sootool/modules/finance/__init__.py +35 -0
- sootool/modules/finance/bond.py +261 -0
- sootool/modules/finance/derivatives.py +259 -0
- sootool/modules/finance/loan.py +214 -0
- sootool/modules/finance/metrics.py +274 -0
- sootool/modules/finance/option.py +191 -0
- sootool/modules/finance/risk.py +318 -0
- sootool/modules/finance/tvm.py +158 -0
- sootool/modules/geometry/README.md +69 -0
- sootool/modules/geometry/__init__.py +9 -0
- sootool/modules/geometry/area.py +183 -0
- sootool/modules/geometry/distance.py +102 -0
- sootool/modules/geometry/matrix_ops.py +233 -0
- sootool/modules/geometry/vector_ops.py +148 -0
- sootool/modules/geometry/volume.py +138 -0
- sootool/modules/math/__init__.py +22 -0
- sootool/modules/math/differentiation.py +116 -0
- sootool/modules/math/fft.py +123 -0
- sootool/modules/math/integration.py +147 -0
- sootool/modules/math/interpolation.py +144 -0
- sootool/modules/math/polynomial.py +114 -0
- sootool/modules/medical/README.md +84 -0
- sootool/modules/medical/__init__.py +16 -0
- sootool/modules/medical/body.py +172 -0
- sootool/modules/medical/dose.py +92 -0
- sootool/modules/medical/egfr.py +150 -0
- sootool/modules/medical/pregnancy.py +115 -0
- sootool/modules/medical/qt_correction.py +179 -0
- sootool/modules/medical/risk_scores.py +260 -0
- sootool/modules/payroll/__init__.py +9 -0
- sootool/modules/payroll/kr_salary.py +184 -0
- sootool/modules/pm/__init__.py +14 -0
- sootool/modules/pm/cpm.py +169 -0
- sootool/modules/pm/earned_schedule.py +150 -0
- sootool/modules/pm/evm.py +115 -0
- sootool/modules/pm/monte_carlo.py +153 -0
- sootool/modules/pm/pert.py +106 -0
- sootool/modules/probability/__init__.py +16 -0
- sootool/modules/probability/bayes.py +78 -0
- sootool/modules/probability/combinatorics.py +140 -0
- sootool/modules/probability/distributions.py +806 -0
- sootool/modules/probability/expected.py +91 -0
- sootool/modules/realestate/README.md +89 -0
- sootool/modules/realestate/__init__.py +23 -0
- sootool/modules/realestate/acquisition_tax.py +150 -0
- sootool/modules/realestate/kr_comprehensive.py +129 -0
- sootool/modules/realestate/kr_property_tax.py +106 -0
- sootool/modules/realestate/ratios.py +250 -0
- sootool/modules/realestate/rental_yield.py +105 -0
- sootool/modules/realestate/transfer_tax.py +63 -0
- sootool/modules/science/__init__.py +14 -0
- sootool/modules/science/chemistry.py +312 -0
- sootool/modules/science/electrochemistry.py +188 -0
- sootool/modules/science/optics.py +282 -0
- sootool/modules/science/periodic_table.py +53 -0
- sootool/modules/science/physics.py +102 -0
- sootool/modules/science/thermo.py +149 -0
- sootool/modules/stats/README.md +41 -0
- sootool/modules/stats/__init__.py +31 -0
- sootool/modules/stats/anova.py +129 -0
- sootool/modules/stats/bootstrap.py +114 -0
- sootool/modules/stats/ci.py +87 -0
- sootool/modules/stats/descriptive.py +85 -0
- sootool/modules/stats/effect_size.py +153 -0
- sootool/modules/stats/inference.py +283 -0
- sootool/modules/stats/nonparametric.py +180 -0
- sootool/modules/stats/regression.py +118 -0
- sootool/modules/tax/README.md +41 -0
- sootool/modules/tax/__init__.py +25 -0
- sootool/modules/tax/capital_gains.py +146 -0
- sootool/modules/tax/kr_corporate.py +137 -0
- sootool/modules/tax/kr_gift.py +118 -0
- sootool/modules/tax/kr_income.py +87 -0
- sootool/modules/tax/kr_inheritance.py +140 -0
- sootool/modules/tax/kr_withholding.py +180 -0
- sootool/modules/tax/progressive.py +205 -0
- sootool/modules/units/README.md +57 -0
- sootool/modules/units/__init__.py +13 -0
- sootool/modules/units/convert.py +69 -0
- sootool/modules/units/currency.py +235 -0
- sootool/modules/units/extended.py +269 -0
- sootool/modules/units/temperature.py +120 -0
- sootool/observability/__init__.py +1 -0
- sootool/observability/health.py +28 -0
- sootool/observability/log_format.py +36 -0
- sootool/policies/__init__.py +161 -0
- sootool/policies/payroll/__init__.py +0 -0
- sootool/policies/payroll/kr_4insurance_2026.yaml +43 -0
- sootool/policies/realestate/__init__.py +0 -0
- sootool/policies/realestate/kr_acquisition_2026.yaml +24 -0
- sootool/policies/realestate/kr_comprehensive_2026.yaml +45 -0
- sootool/policies/realestate/kr_dsr_ltv_2026.yaml +14 -0
- sootool/policies/realestate/kr_property_tax_2026.yaml +22 -0
- sootool/policies/tax/__init__.py +0 -0
- sootool/policies/tax/kr_capital_gains_2026.yaml +100 -0
- sootool/policies/tax/kr_corporate_2026.yaml +29 -0
- sootool/policies/tax/kr_gift_2026.yaml +25 -0
- sootool/policies/tax/kr_income_2026.yaml +24 -0
- sootool/policies/tax/kr_inheritance_2026.yaml +23 -0
- sootool/policies/tax/kr_withholding_2026.yaml +50 -0
- sootool/policy_mgmt/__init__.py +10 -0
- sootool/policy_mgmt/audit.py +106 -0
- sootool/policy_mgmt/diff.py +144 -0
- sootool/policy_mgmt/drafts.py +154 -0
- sootool/policy_mgmt/loader.py +201 -0
- sootool/policy_mgmt/paths.py +79 -0
- sootool/policy_mgmt/schemas.py +177 -0
- sootool/policy_mgmt/signatures.py +69 -0
- sootool/policy_mgmt/tools.py +577 -0
- sootool/policy_mgmt/trace_ext.py +74 -0
- sootool/policy_mgmt/validators.py +353 -0
- sootool/server.py +310 -0
- sootool/skill_guide/__init__.py +8 -0
- sootool/skill_guide/anti_patterns.py +97 -0
- sootool/skill_guide/examples.py +187 -0
- sootool/skill_guide/guide.py +67 -0
- sootool/skill_guide/hints.py +142 -0
- sootool/skill_guide/locale.py +75 -0
- sootool/skill_guide/playbooks.py +395 -0
- sootool/skill_guide/session_state.py +143 -0
- sootool/skill_guide/triggers.py +297 -0
- sootool/transports/__init__.py +1 -0
- sootool/transports/base.py +8 -0
- sootool/transports/http.py +98 -0
- sootool/transports/sse_legacy.py +127 -0
- sootool/transports/stdio.py +14 -0
- sootool/transports/unix.py +206 -0
- sootool/transports/websocket.py +253 -0
- sootool-0.1.1.dist-info/METADATA +238 -0
- sootool-0.1.1.dist-info/RECORD +193 -0
- sootool-0.1.1.dist-info/WHEEL +4 -0
- sootool-0.1.1.dist-info/entry_points.txt +3 -0
sootool/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError
|
|
2
|
+
from importlib.metadata import version as _v
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = _v("sootool")
|
|
6
|
+
except PackageNotFoundError:
|
|
7
|
+
__version__ = "0.0.0+unknown"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
print("Hello from sootool!")
|
sootool/__main__.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from sootool.observability.log_format import JsonFormatter
|
|
10
|
+
from sootool.server import _load_modules, build_server
|
|
11
|
+
|
|
12
|
+
_VALID_TRANSPORTS = {"stdio", "http", "sse-legacy", "websocket", "unix"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _configure_logging(log_format: str, log_level: str) -> None:
|
|
16
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
17
|
+
if log_format == "json":
|
|
18
|
+
handler.setFormatter(JsonFormatter())
|
|
19
|
+
else:
|
|
20
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
|
21
|
+
root = logging.getLogger()
|
|
22
|
+
root.handlers.clear()
|
|
23
|
+
root.addHandler(handler)
|
|
24
|
+
root.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_transports(raw: str, args: argparse.Namespace | None = None) -> list[str]:
|
|
28
|
+
if raw.strip() == "all":
|
|
29
|
+
# "all" activates stdio + http + opt-in transports that are enabled
|
|
30
|
+
base = ["stdio", "http"]
|
|
31
|
+
if args is not None:
|
|
32
|
+
if getattr(args, "enable_sse_legacy", False) or os.environ.get("SOOTOOL_ENABLE_SSE_LEGACY"):
|
|
33
|
+
base.append("sse-legacy")
|
|
34
|
+
if getattr(args, "enable_websocket", False) or os.environ.get("SOOTOOL_ENABLE_WEBSOCKET"):
|
|
35
|
+
base.append("websocket")
|
|
36
|
+
socket_path = getattr(args, "socket", None) or os.environ.get("SOOTOOL_SOCKET_PATH")
|
|
37
|
+
if socket_path:
|
|
38
|
+
base.append("unix")
|
|
39
|
+
return base
|
|
40
|
+
parts = [t.strip() for t in raw.split(",") if t.strip()]
|
|
41
|
+
unknown = set(parts) - _VALID_TRANSPORTS
|
|
42
|
+
if unknown:
|
|
43
|
+
raise argparse.ArgumentTypeError(
|
|
44
|
+
f"unknown transport(s): {', '.join(sorted(unknown))}. "
|
|
45
|
+
f"valid choices: {', '.join(sorted(_VALID_TRANSPORTS))}"
|
|
46
|
+
)
|
|
47
|
+
return parts
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_security(
|
|
51
|
+
host: str,
|
|
52
|
+
auth_token: str | None,
|
|
53
|
+
transports: list[str],
|
|
54
|
+
) -> None:
|
|
55
|
+
effective_token = auth_token or os.environ.get("SOOTOOL_AUTH_TOKEN")
|
|
56
|
+
network_transports = {"http", "sse-legacy", "websocket"}
|
|
57
|
+
if host != "127.0.0.1" and not effective_token:
|
|
58
|
+
if any(t in transports for t in network_transports):
|
|
59
|
+
sys.exit(
|
|
60
|
+
"ERROR: --host is set to a non-loopback address but no authentication token is "
|
|
61
|
+
"configured.\n"
|
|
62
|
+
"Set SOOTOOL_AUTH_TOKEN or pass --auth-token <token> to enable bearer auth "
|
|
63
|
+
"before exposing the server externally."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
parser = argparse.ArgumentParser(
|
|
69
|
+
prog="sootool",
|
|
70
|
+
description="SooTool MCP server",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--transport",
|
|
74
|
+
default="stdio",
|
|
75
|
+
help=(
|
|
76
|
+
"Transport(s) to activate. Comma-separated or 'all'. "
|
|
77
|
+
"Valid: stdio, http, sse-legacy, websocket, unix. Default: stdio."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--host", default="127.0.0.1", help="Network bind address (default 127.0.0.1)")
|
|
81
|
+
parser.add_argument("--http-port", type=int, default=10535, dest="http_port", help="Streamable HTTP port")
|
|
82
|
+
parser.add_argument("--sse-port", type=int, default=10536, dest="sse_port", help="SSE legacy port (default 10536)")
|
|
83
|
+
parser.add_argument("--ws-port", type=int, default=10537, dest="ws_port", help="WebSocket port (default 10537)")
|
|
84
|
+
parser.add_argument("--auth-token", default=None, dest="auth_token", help="Bearer token for network transports")
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--cors-origin",
|
|
87
|
+
action="append",
|
|
88
|
+
default=[],
|
|
89
|
+
dest="cors_origins",
|
|
90
|
+
metavar="ORIGIN",
|
|
91
|
+
help="Allowed CORS origin (repeatable). Falls back to SOOTOOL_CORS_ORIGINS env.",
|
|
92
|
+
)
|
|
93
|
+
# SSE legacy
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--enable-sse-legacy",
|
|
96
|
+
action="store_true",
|
|
97
|
+
default=False,
|
|
98
|
+
dest="enable_sse_legacy",
|
|
99
|
+
help="Enable HTTP+SSE legacy transport (MCP 2024-11). Also: SOOTOOL_ENABLE_SSE_LEGACY=1",
|
|
100
|
+
)
|
|
101
|
+
# WebSocket
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--enable-websocket",
|
|
104
|
+
action="store_true",
|
|
105
|
+
default=False,
|
|
106
|
+
dest="enable_websocket",
|
|
107
|
+
help="Enable WebSocket transport. Also: SOOTOOL_ENABLE_WEBSOCKET=1",
|
|
108
|
+
)
|
|
109
|
+
# Unix socket
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--socket",
|
|
112
|
+
default=None,
|
|
113
|
+
dest="socket",
|
|
114
|
+
metavar="PATH",
|
|
115
|
+
help="Unix domain socket path. Also: SOOTOOL_SOCKET_PATH",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--socket-mode",
|
|
119
|
+
default="0600",
|
|
120
|
+
dest="socket_mode",
|
|
121
|
+
metavar="MODE",
|
|
122
|
+
help="Unix socket file permissions (octal, default 0600)",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--force-socket",
|
|
126
|
+
action="store_true",
|
|
127
|
+
default=False,
|
|
128
|
+
dest="force_socket",
|
|
129
|
+
help="Remove stale Unix socket file on startup instead of refusing to start",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("--log-format", choices=["json", "text"], default="json", dest="log_format")
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--log-level",
|
|
134
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
135
|
+
default="INFO",
|
|
136
|
+
dest="log_level",
|
|
137
|
+
)
|
|
138
|
+
return parser
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _run(transports: list[str], args: argparse.Namespace) -> None:
|
|
142
|
+
server = build_server()
|
|
143
|
+
|
|
144
|
+
from sootool.transports.http import HttpTransport
|
|
145
|
+
from sootool.transports.sse_legacy import SseLegacyTransport
|
|
146
|
+
from sootool.transports.stdio import StdioTransport
|
|
147
|
+
from sootool.transports.unix import UnixTransport
|
|
148
|
+
from sootool.transports.websocket import WebSocketTransport
|
|
149
|
+
|
|
150
|
+
tasks = []
|
|
151
|
+
|
|
152
|
+
if "stdio" in transports and any(t in transports for t in ("http", "sse-legacy", "websocket")):
|
|
153
|
+
logging.getLogger("sootool").warning(
|
|
154
|
+
"Running stdio and network transport(s) simultaneously. When managed by "
|
|
155
|
+
"systemd/supervisor, ensure stdin/stdout are not shared with HTTP processes."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if "stdio" in transports:
|
|
159
|
+
tasks.append(StdioTransport(server).start_async())
|
|
160
|
+
|
|
161
|
+
if "http" in transports:
|
|
162
|
+
tasks.append(
|
|
163
|
+
HttpTransport(
|
|
164
|
+
server=server,
|
|
165
|
+
host=args.host,
|
|
166
|
+
port=args.http_port,
|
|
167
|
+
auth_token=args.auth_token,
|
|
168
|
+
cors_origins=args.cors_origins,
|
|
169
|
+
log_level=args.log_level.lower(),
|
|
170
|
+
).start_async()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if "sse-legacy" in transports:
|
|
174
|
+
tasks.append(
|
|
175
|
+
SseLegacyTransport(
|
|
176
|
+
server=server,
|
|
177
|
+
host=args.host,
|
|
178
|
+
port=args.sse_port,
|
|
179
|
+
auth_token=args.auth_token,
|
|
180
|
+
cors_origins=args.cors_origins,
|
|
181
|
+
log_level=args.log_level.lower(),
|
|
182
|
+
).start_async()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if "websocket" in transports:
|
|
186
|
+
tasks.append(
|
|
187
|
+
WebSocketTransport(
|
|
188
|
+
server=server,
|
|
189
|
+
host=args.host,
|
|
190
|
+
port=args.ws_port,
|
|
191
|
+
auth_token=args.auth_token,
|
|
192
|
+
cors_origins=args.cors_origins,
|
|
193
|
+
log_level=args.log_level.lower(),
|
|
194
|
+
).start_async()
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if "unix" in transports:
|
|
198
|
+
socket_mode_str = getattr(args, "socket_mode", "0600")
|
|
199
|
+
try:
|
|
200
|
+
socket_mode = int(socket_mode_str, 8)
|
|
201
|
+
except ValueError:
|
|
202
|
+
sys.exit(f"ERROR: invalid --socket-mode value: {socket_mode_str!r} (expected octal)")
|
|
203
|
+
tasks.append(
|
|
204
|
+
UnixTransport(
|
|
205
|
+
server=server,
|
|
206
|
+
socket_path=args.socket,
|
|
207
|
+
socket_mode=socket_mode,
|
|
208
|
+
force=args.force_socket,
|
|
209
|
+
).start_async()
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if not tasks:
|
|
213
|
+
sys.exit("No transports selected.")
|
|
214
|
+
|
|
215
|
+
await asyncio.gather(*tasks)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main() -> None:
|
|
219
|
+
parser = _build_parser()
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
|
|
222
|
+
_configure_logging(args.log_format, args.log_level)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
transports = _parse_transports(args.transport, args)
|
|
226
|
+
except argparse.ArgumentTypeError as exc:
|
|
227
|
+
parser.error(str(exc))
|
|
228
|
+
|
|
229
|
+
# Opt-in flags augment explicit transport list
|
|
230
|
+
if args.enable_sse_legacy and "sse-legacy" not in transports:
|
|
231
|
+
transports.append("sse-legacy")
|
|
232
|
+
if args.enable_websocket and "websocket" not in transports:
|
|
233
|
+
transports.append("websocket")
|
|
234
|
+
unix_path = args.socket or os.environ.get("SOOTOOL_SOCKET_PATH")
|
|
235
|
+
if unix_path and "unix" not in transports and args.transport.strip() != "all":
|
|
236
|
+
transports.append("unix")
|
|
237
|
+
|
|
238
|
+
_validate_security(args.host, args.auth_token, transports)
|
|
239
|
+
|
|
240
|
+
_load_modules()
|
|
241
|
+
asyncio.run(_run(transports, args))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
main()
|
sootool/core/__init__.py
ADDED
|
File without changes
|
sootool/core/audit.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _normalize(v: Any) -> Any:
|
|
9
|
+
if isinstance(v, Decimal):
|
|
10
|
+
return str(v)
|
|
11
|
+
if isinstance(v, (list, tuple)):
|
|
12
|
+
return [_normalize(x) for x in v]
|
|
13
|
+
if isinstance(v, dict):
|
|
14
|
+
return {k: _normalize(x) for k, x in v.items()}
|
|
15
|
+
return v
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CalcTrace:
|
|
20
|
+
tool: str
|
|
21
|
+
formula: str = ""
|
|
22
|
+
inputs: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
|
24
|
+
output_value: Any = None
|
|
25
|
+
|
|
26
|
+
def input(self, name: str, value: Any) -> None:
|
|
27
|
+
self.inputs[name] = _normalize(value)
|
|
28
|
+
|
|
29
|
+
def step(self, label: str, value: Any) -> None:
|
|
30
|
+
self.steps.append({"label": label, "value": _normalize(value)})
|
|
31
|
+
|
|
32
|
+
def output(self, value: Any) -> None:
|
|
33
|
+
self.output_value = _normalize(value)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"tool": self.tool,
|
|
38
|
+
"formula": self.formula,
|
|
39
|
+
"inputs": self.inputs,
|
|
40
|
+
"steps": self.steps,
|
|
41
|
+
"output": self.output_value,
|
|
42
|
+
}
|
sootool/core/batch.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
5
|
+
from concurrent.futures import TimeoutError as FuturesTimeout
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sootool.core.errors import SooToolError
|
|
10
|
+
from sootool.core.registry import ToolRegistry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BatchLimitError(SooToolError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BatchExecutor:
|
|
19
|
+
registry: ToolRegistry
|
|
20
|
+
max_items: int = 500
|
|
21
|
+
item_timeout_s: float = 10.0
|
|
22
|
+
batch_timeout_s: float = 60.0
|
|
23
|
+
max_workers: int = 16
|
|
24
|
+
deterministic: bool = True
|
|
25
|
+
|
|
26
|
+
def run(self, items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
27
|
+
if len(items) > self.max_items:
|
|
28
|
+
raise BatchLimitError(f"배치 항목 수 {len(items)} > 한도 {self.max_items}")
|
|
29
|
+
ids = [it["id"] for it in items]
|
|
30
|
+
if len(set(ids)) != len(ids):
|
|
31
|
+
raise ValueError("배치 항목 id 중복")
|
|
32
|
+
|
|
33
|
+
started = time.monotonic()
|
|
34
|
+
workers = min(self.max_workers, max(1, len(items)))
|
|
35
|
+
results: dict[str, dict[str, Any]] = {}
|
|
36
|
+
|
|
37
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
38
|
+
futures: dict[Future[Any], str] = {}
|
|
39
|
+
for it in items:
|
|
40
|
+
fut = pool.submit(self.registry.invoke, it["tool"], **it.get("args", {}))
|
|
41
|
+
futures[fut] = it["id"]
|
|
42
|
+
|
|
43
|
+
if self.deterministic:
|
|
44
|
+
# Collect by future mapped to item id, then order by input order
|
|
45
|
+
id_to_future: dict[str, Future[Any]] = {v: k for k, v in futures.items()}
|
|
46
|
+
for item_id, fut in id_to_future.items():
|
|
47
|
+
t0 = time.monotonic()
|
|
48
|
+
try:
|
|
49
|
+
res = fut.result(timeout=self.item_timeout_s)
|
|
50
|
+
results[item_id] = {
|
|
51
|
+
"id": item_id,
|
|
52
|
+
"status": "ok",
|
|
53
|
+
"result": res,
|
|
54
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
55
|
+
}
|
|
56
|
+
except FuturesTimeout:
|
|
57
|
+
fut.cancel()
|
|
58
|
+
results[item_id] = {
|
|
59
|
+
"id": item_id,
|
|
60
|
+
"status": "timeout",
|
|
61
|
+
"error": {"type": "TimeoutError", "message": f"item_timeout_s={self.item_timeout_s}"},
|
|
62
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
63
|
+
}
|
|
64
|
+
except Exception as e:
|
|
65
|
+
results[item_id] = {
|
|
66
|
+
"id": item_id,
|
|
67
|
+
"status": "error",
|
|
68
|
+
"error": {"type": type(e).__name__, "message": str(e)},
|
|
69
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
70
|
+
}
|
|
71
|
+
else:
|
|
72
|
+
# Non-deterministic: collect by completion order
|
|
73
|
+
for fut in as_completed(futures):
|
|
74
|
+
item_id = futures[fut]
|
|
75
|
+
t0 = time.monotonic()
|
|
76
|
+
try:
|
|
77
|
+
res = fut.result(timeout=0)
|
|
78
|
+
results[item_id] = {
|
|
79
|
+
"id": item_id,
|
|
80
|
+
"status": "ok",
|
|
81
|
+
"result": res,
|
|
82
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
83
|
+
}
|
|
84
|
+
except FuturesTimeout:
|
|
85
|
+
results[item_id] = {
|
|
86
|
+
"id": item_id,
|
|
87
|
+
"status": "timeout",
|
|
88
|
+
"error": {"type": "TimeoutError", "message": f"item_timeout_s={self.item_timeout_s}"},
|
|
89
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
90
|
+
}
|
|
91
|
+
except Exception as e:
|
|
92
|
+
results[item_id] = {
|
|
93
|
+
"id": item_id,
|
|
94
|
+
"status": "error",
|
|
95
|
+
"error": {"type": type(e).__name__, "message": str(e)},
|
|
96
|
+
"elapsed_ms": int((time.monotonic() - t0) * 1000),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
total_ms = int((time.monotonic() - started) * 1000)
|
|
100
|
+
|
|
101
|
+
if self.deterministic:
|
|
102
|
+
ordered = [results[it["id"]] for it in items]
|
|
103
|
+
else:
|
|
104
|
+
# Ordered by completion time (already in insertion order via dict)
|
|
105
|
+
ordered = list(results.values())
|
|
106
|
+
|
|
107
|
+
count_ok = sum(1 for r in ordered if r["status"] == "ok")
|
|
108
|
+
count_error = sum(1 for r in ordered if r["status"] == "error")
|
|
109
|
+
count_timeout = sum(1 for r in ordered if r["status"] == "timeout")
|
|
110
|
+
|
|
111
|
+
if count_ok == len(ordered):
|
|
112
|
+
status = "all_ok"
|
|
113
|
+
elif count_ok == 0:
|
|
114
|
+
status = "all_failed"
|
|
115
|
+
else:
|
|
116
|
+
status = "partial"
|
|
117
|
+
|
|
118
|
+
response: dict[str, Any] = {
|
|
119
|
+
"status": status,
|
|
120
|
+
"results": ordered,
|
|
121
|
+
"count_ok": count_ok,
|
|
122
|
+
"count_error": count_error,
|
|
123
|
+
"count_timeout": count_timeout,
|
|
124
|
+
"total_time_ms": total_ms,
|
|
125
|
+
"parallelism": workers,
|
|
126
|
+
}
|
|
127
|
+
if not self.deterministic:
|
|
128
|
+
response["non_deterministic"] = True
|
|
129
|
+
return response
|