schemathesis 3.13.0__py3-none-any.whl → 4.4.2__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.
- schemathesis/__init__.py +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1016
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +683 -247
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +27 -0
- schemathesis/specs/graphql/scalars.py +86 -0
- schemathesis/specs/graphql/schemas.py +395 -123
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +578 -317
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +753 -74
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +117 -68
- schemathesis/specs/openapi/negative/mutations.py +294 -104
- schemathesis/specs/openapi/negative/utils.py +3 -6
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +648 -650
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +404 -69
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -41
- schemathesis/_hypothesis.py +0 -115
- schemathesis/cli/callbacks.py +0 -188
- schemathesis/cli/cassettes.py +0 -253
- schemathesis/cli/context.py +0 -36
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -51
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -508
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -79
- schemathesis/exceptions.py +0 -207
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -216
- schemathesis/failures.py +0 -131
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/lazy.py +0 -227
- schemathesis/models.py +0 -1041
- schemathesis/parameters.py +0 -88
- schemathesis/runner/__init__.py +0 -460
- schemathesis/runner/events.py +0 -240
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -755
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -189
- schemathesis/serializers.py +0 -233
- schemathesis/service/__init__.py +0 -3
- schemathesis/service/client.py +0 -46
- schemathesis/service/constants.py +0 -12
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -39
- schemathesis/service/models.py +0 -7
- schemathesis/service/serialization.py +0 -153
- schemathesis/service/worker.py +0 -40
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -302
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -413
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -349
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -436
- schemathesis-3.13.0.dist-info/METADATA +0 -202
- schemathesis-3.13.0.dist-info/RECORD +0 -91
- schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from typing import TYPE_CHECKING, ContextManager
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from schemathesis.core.errors import InvalidRateLimit
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pyrate_limiter import Duration, Limiter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ratelimit(rate_limiter: Limiter | None, base_url: str | None) -> ContextManager:
|
|
14
|
+
"""Limit the rate of sending generated requests."""
|
|
15
|
+
label = urlparse(base_url).netloc
|
|
16
|
+
if rate_limiter is not None:
|
|
17
|
+
rate_limiter.try_acquire(label)
|
|
18
|
+
return nullcontext()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_units(rate: str) -> tuple[int, int]:
|
|
22
|
+
from pyrate_limiter import Duration
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
limit, interval_text = rate.split("/")
|
|
26
|
+
interval = {
|
|
27
|
+
"s": Duration.SECOND,
|
|
28
|
+
"m": Duration.MINUTE,
|
|
29
|
+
"h": Duration.HOUR,
|
|
30
|
+
"d": Duration.DAY,
|
|
31
|
+
}.get(interval_text)
|
|
32
|
+
if interval is None:
|
|
33
|
+
raise InvalidRateLimit(rate)
|
|
34
|
+
return int(limit), interval
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
raise InvalidRateLimit(rate) from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_max_delay(value: int, unit: Duration) -> int:
|
|
40
|
+
from pyrate_limiter import Duration
|
|
41
|
+
|
|
42
|
+
if unit == Duration.SECOND:
|
|
43
|
+
multiplier = 1
|
|
44
|
+
elif unit == Duration.MINUTE:
|
|
45
|
+
multiplier = 60
|
|
46
|
+
elif unit == Duration.HOUR:
|
|
47
|
+
multiplier = 60 * 60
|
|
48
|
+
else:
|
|
49
|
+
multiplier = 60 * 60 * 24
|
|
50
|
+
# Delay is in milliseconds + `pyrate_limiter` adds 50ms on top.
|
|
51
|
+
# Hence adding 100 covers this
|
|
52
|
+
return value * multiplier * 1000 + 100
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_limiter(rate: str) -> Limiter:
|
|
56
|
+
from pyrate_limiter import Limiter, Rate
|
|
57
|
+
|
|
58
|
+
limit, interval = parse_units(rate)
|
|
59
|
+
rate = Rate(limit, interval)
|
|
60
|
+
return Limiter(rate, max_delay=_get_max_delay(limit, interval))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Generic, Sequence, TypeVar, Union
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T", bound=Union[Callable, type])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Registry(Generic[T]):
|
|
9
|
+
"""Container for Schemathesis extensions."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("_items",)
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self._items: dict[str, T] = {}
|
|
15
|
+
|
|
16
|
+
def register(self, item: T) -> T:
|
|
17
|
+
self._items[item.__name__] = item
|
|
18
|
+
return item
|
|
19
|
+
|
|
20
|
+
def unregister(self, name: str) -> None:
|
|
21
|
+
del self._items[name]
|
|
22
|
+
|
|
23
|
+
def get_all_names(self) -> list[str]:
|
|
24
|
+
return list(self._items)
|
|
25
|
+
|
|
26
|
+
def get_all(self) -> list[T]:
|
|
27
|
+
return list(self._items.values())
|
|
28
|
+
|
|
29
|
+
def get_one(self, name: str) -> T:
|
|
30
|
+
return self._items[name]
|
|
31
|
+
|
|
32
|
+
def get_by_names(self, names: Sequence[str]) -> list[T]:
|
|
33
|
+
"""Get items by their names."""
|
|
34
|
+
return [self._items[name] for name in names]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Generic, TypeVar, Union
|
|
2
|
+
|
|
3
|
+
T = TypeVar("T")
|
|
4
|
+
E = TypeVar("E", bound=Exception)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Ok(Generic[T]):
|
|
8
|
+
__slots__ = ("_value",)
|
|
9
|
+
|
|
10
|
+
def __init__(self, value: T):
|
|
11
|
+
self._value = value
|
|
12
|
+
|
|
13
|
+
def ok(self) -> T:
|
|
14
|
+
return self._value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Err(Generic[E]):
|
|
18
|
+
__slots__ = ("_error",)
|
|
19
|
+
|
|
20
|
+
def __init__(self, error: E):
|
|
21
|
+
self._error = error
|
|
22
|
+
|
|
23
|
+
def err(self) -> E:
|
|
24
|
+
return self._error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Result = Union[Ok[T], Err[E]]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from schemathesis.config import SchemathesisWarning
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaWarning(Protocol):
|
|
9
|
+
"""Shared interface for static schema analysis warnings."""
|
|
10
|
+
|
|
11
|
+
operation_label: str
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def kind(self) -> SchemathesisWarning: ...
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def message(self) -> str: ...
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Shell detection and escaping for generating reproducible curl commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ShellType(str, Enum):
|
|
11
|
+
"""Supported shell types."""
|
|
12
|
+
|
|
13
|
+
BASH = "bash"
|
|
14
|
+
ZSH = "zsh"
|
|
15
|
+
FISH = "fish"
|
|
16
|
+
UNKNOWN = "unknown"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def supports_ansi_c_quoting(self) -> bool:
|
|
20
|
+
r"""Whether shell supports $'...\xHH' syntax."""
|
|
21
|
+
return self in (ShellType.BASH, ShellType.ZSH)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def supports_hex_in_quotes(self) -> bool:
|
|
25
|
+
r"""Whether shell interprets \xHH in single quotes."""
|
|
26
|
+
return self == ShellType.FISH
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class EscapeResult:
|
|
31
|
+
"""Result of escaping a value for shell."""
|
|
32
|
+
|
|
33
|
+
escaped_value: str
|
|
34
|
+
"""The escaped string ready for shell."""
|
|
35
|
+
|
|
36
|
+
needs_warning: bool
|
|
37
|
+
"""Whether a warning should be shown to the user."""
|
|
38
|
+
|
|
39
|
+
original_bytes: bytes | None
|
|
40
|
+
"""Original bytes if warning is needed, for detailed display."""
|
|
41
|
+
|
|
42
|
+
shell_used: ShellType
|
|
43
|
+
"""Which shell type the escaping is for."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("escaped_value", "needs_warning", "original_bytes", "shell_used")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_DETECTED_SHELL: ShellType | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_shell() -> ShellType:
|
|
52
|
+
"""Detect the current shell type from $SHELL environment variable."""
|
|
53
|
+
global _DETECTED_SHELL
|
|
54
|
+
|
|
55
|
+
if _DETECTED_SHELL is not None:
|
|
56
|
+
return _DETECTED_SHELL
|
|
57
|
+
|
|
58
|
+
# Check $SHELL environment variable
|
|
59
|
+
shell_path = os.environ.get("SHELL", "")
|
|
60
|
+
if shell_path:
|
|
61
|
+
shell_name = os.path.basename(shell_path).lower()
|
|
62
|
+
detected = _parse_shell_name(shell_name)
|
|
63
|
+
_DETECTED_SHELL = detected
|
|
64
|
+
return detected
|
|
65
|
+
|
|
66
|
+
_DETECTED_SHELL = ShellType.UNKNOWN
|
|
67
|
+
return ShellType.UNKNOWN
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_shell_name(name: str) -> ShellType:
|
|
71
|
+
"""Parse shell name string to ShellType."""
|
|
72
|
+
name_lower = name.lower()
|
|
73
|
+
|
|
74
|
+
# Check exact matches first
|
|
75
|
+
for shell_type in (ShellType.BASH, ShellType.ZSH, ShellType.FISH):
|
|
76
|
+
if shell_type.value == name_lower:
|
|
77
|
+
return shell_type
|
|
78
|
+
|
|
79
|
+
# Check substring matches
|
|
80
|
+
for shell_type in (ShellType.BASH, ShellType.ZSH, ShellType.FISH):
|
|
81
|
+
if shell_type.value in name_lower:
|
|
82
|
+
return shell_type
|
|
83
|
+
|
|
84
|
+
return ShellType.UNKNOWN
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def has_non_printable(value: str | bytes) -> bool:
|
|
88
|
+
"""Check if value contains ASCII control characters."""
|
|
89
|
+
if isinstance(value, bytes):
|
|
90
|
+
try:
|
|
91
|
+
value = value.decode("utf-8")
|
|
92
|
+
except UnicodeDecodeError:
|
|
93
|
+
# Binary data that can't be decoded - treat as non-printable
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
# Check for ASCII control characters: 0-31 and 127 (DEL)
|
|
97
|
+
return any(ord(c) < 32 or ord(c) == 127 for c in value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def escape_for_shell(value: str, shell: ShellType | None = None) -> EscapeResult:
|
|
101
|
+
"""Escape value for shell use in curl commands."""
|
|
102
|
+
if shell is None:
|
|
103
|
+
shell = detect_shell()
|
|
104
|
+
|
|
105
|
+
# Fast path: no non-printable characters
|
|
106
|
+
if not has_non_printable(value):
|
|
107
|
+
return EscapeResult(
|
|
108
|
+
escaped_value=value,
|
|
109
|
+
needs_warning=False,
|
|
110
|
+
original_bytes=None,
|
|
111
|
+
shell_used=shell,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
original_bytes = value.encode("utf-8")
|
|
115
|
+
|
|
116
|
+
# Bash/Zsh: Use ANSI-C quoting $'...\xHH'
|
|
117
|
+
if shell.supports_ansi_c_quoting:
|
|
118
|
+
escaped = _escape_with_ansi_c(value)
|
|
119
|
+
return EscapeResult(
|
|
120
|
+
escaped_value=f"$'{escaped}'",
|
|
121
|
+
needs_warning=False,
|
|
122
|
+
original_bytes=None,
|
|
123
|
+
shell_used=shell,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Fish: Use \xHH in single quotes
|
|
127
|
+
if shell.supports_hex_in_quotes:
|
|
128
|
+
escaped = _escape_with_hex(value)
|
|
129
|
+
return EscapeResult(
|
|
130
|
+
escaped_value=f"'{escaped}'",
|
|
131
|
+
needs_warning=False,
|
|
132
|
+
original_bytes=None,
|
|
133
|
+
shell_used=shell,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Unknown shell: Show bash-style with warning
|
|
137
|
+
escaped = _escape_with_ansi_c(value)
|
|
138
|
+
return EscapeResult(
|
|
139
|
+
escaped_value=f"$'{escaped}'",
|
|
140
|
+
needs_warning=True,
|
|
141
|
+
original_bytes=original_bytes,
|
|
142
|
+
shell_used=ShellType.BASH,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _escape_with_ansi_c(value: str) -> str:
|
|
147
|
+
"""Escape string for ANSI-C quoting ($'...') used in bash/zsh."""
|
|
148
|
+
result = []
|
|
149
|
+
for char in value:
|
|
150
|
+
code = ord(char)
|
|
151
|
+
|
|
152
|
+
# Readable escapes for common control characters
|
|
153
|
+
if char == "\t":
|
|
154
|
+
result.append("\\t")
|
|
155
|
+
elif char == "\n":
|
|
156
|
+
result.append("\\n")
|
|
157
|
+
elif char == "\r":
|
|
158
|
+
result.append("\\r")
|
|
159
|
+
elif code < 32:
|
|
160
|
+
# Other control characters as hex
|
|
161
|
+
result.append(f"\\x{code:02x}")
|
|
162
|
+
elif code == 127:
|
|
163
|
+
# DEL character
|
|
164
|
+
result.append("\\x7f")
|
|
165
|
+
elif char in ("'", "\\", "$", "`"):
|
|
166
|
+
# Shell special characters that need escaping in $'...'
|
|
167
|
+
result.append(f"\\{char}")
|
|
168
|
+
else:
|
|
169
|
+
result.append(char)
|
|
170
|
+
|
|
171
|
+
return "".join(result)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _escape_with_hex(value: str) -> str:
|
|
175
|
+
r"""Escape string with \xHH notation for fish shell.
|
|
176
|
+
|
|
177
|
+
Fish interprets \x escapes directly in single quotes.
|
|
178
|
+
We still need to escape single quotes and backslashes.
|
|
179
|
+
"""
|
|
180
|
+
result = []
|
|
181
|
+
for char in value:
|
|
182
|
+
code = ord(char)
|
|
183
|
+
|
|
184
|
+
# Readable escapes for common control characters
|
|
185
|
+
if char == "\t":
|
|
186
|
+
result.append("\\t")
|
|
187
|
+
elif char == "\n":
|
|
188
|
+
result.append("\\n")
|
|
189
|
+
elif char == "\r":
|
|
190
|
+
result.append("\\r")
|
|
191
|
+
elif code < 32 or code == 127:
|
|
192
|
+
# Control characters as hex
|
|
193
|
+
result.append(f"\\x{code:02x}")
|
|
194
|
+
elif char == "'":
|
|
195
|
+
# Escape single quote for fish
|
|
196
|
+
result.append("\\'")
|
|
197
|
+
elif char == "\\":
|
|
198
|
+
# Escape backslash
|
|
199
|
+
result.append("\\\\")
|
|
200
|
+
else:
|
|
201
|
+
result.append(char)
|
|
202
|
+
|
|
203
|
+
return "".join(result)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, TypeVar, Union, overload
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@overload
|
|
9
|
+
def deepclone(value: dict) -> dict: ... # pragma: no cover
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@overload
|
|
13
|
+
def deepclone(value: list) -> list: ... # pragma: no cover
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def deepclone(value: T) -> T: ... # pragma: no cover
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def deepclone(value: Any) -> Any:
|
|
21
|
+
"""A specialized version of `deepcopy` that copies only `dict` and `list` and does unrolling.
|
|
22
|
+
|
|
23
|
+
It is on average 3x faster than `deepcopy` and given the amount of calls, it is an important optimization.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(value, dict):
|
|
26
|
+
return {
|
|
27
|
+
k1: (
|
|
28
|
+
{
|
|
29
|
+
k2: (
|
|
30
|
+
{k3: deepclone(v3) for k3, v3 in v2.items()}
|
|
31
|
+
if isinstance(v2, dict)
|
|
32
|
+
else [deepclone(v3) for v3 in v2]
|
|
33
|
+
if isinstance(v2, list)
|
|
34
|
+
else v2
|
|
35
|
+
)
|
|
36
|
+
for k2, v2 in v1.items()
|
|
37
|
+
}
|
|
38
|
+
if isinstance(v1, dict)
|
|
39
|
+
else [deepclone(v2) for v2 in v1]
|
|
40
|
+
if isinstance(v1, list)
|
|
41
|
+
else v1
|
|
42
|
+
)
|
|
43
|
+
for k1, v1 in value.items()
|
|
44
|
+
}
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return [
|
|
47
|
+
{k2: deepclone(v2) for k2, v2 in v1.items()}
|
|
48
|
+
if isinstance(v1, dict)
|
|
49
|
+
else [deepclone(v2) for v2 in v1]
|
|
50
|
+
if isinstance(v1, list)
|
|
51
|
+
else v1
|
|
52
|
+
for v1 in value
|
|
53
|
+
]
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def diff(left: Mapping[str, Any], right: Mapping[str, Any]) -> dict[str, Any]:
|
|
58
|
+
"""Calculate the difference between two dictionaries."""
|
|
59
|
+
diff = {}
|
|
60
|
+
for key, value in right.items():
|
|
61
|
+
if key not in left or left[key] != value:
|
|
62
|
+
diff[key] = value
|
|
63
|
+
return diff
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def merge_at(data: dict[str, Any], data_key: str, new: dict[str, Any]) -> None:
|
|
67
|
+
original = data[data_key] or {}
|
|
68
|
+
for key, value in new.items():
|
|
69
|
+
original[key] = value
|
|
70
|
+
data[data_key] = original
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
JsonValue = Union[Dict[str, Any], List, str, float, int]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@overload
|
|
77
|
+
def transform(schema: dict[str, Any], callback: Callable, *args: Any, **kwargs: Any) -> dict[str, Any]: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@overload
|
|
81
|
+
def transform(schema: list, callback: Callable, *args: Any, **kwargs: Any) -> list: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@overload
|
|
85
|
+
def transform(schema: str, callback: Callable, *args: Any, **kwargs: Any) -> str: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@overload
|
|
89
|
+
def transform(schema: float, callback: Callable, *args: Any, **kwargs: Any) -> float: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def transform(schema: JsonValue, callback: Callable[..., dict[str, Any]], *args: Any, **kwargs: Any) -> JsonValue:
|
|
93
|
+
"""Apply callback recursively to the given schema."""
|
|
94
|
+
if isinstance(schema, dict):
|
|
95
|
+
schema = callback(schema, *args, **kwargs)
|
|
96
|
+
for key, sub_item in schema.items():
|
|
97
|
+
schema[key] = transform(sub_item, callback, *args, **kwargs)
|
|
98
|
+
elif isinstance(schema, list):
|
|
99
|
+
schema = [transform(sub_item, callback, *args, **kwargs) for sub_item in schema]
|
|
100
|
+
return schema
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Unresolvable: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
UNRESOLVABLE = Unresolvable()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def encode_pointer(pointer: str) -> str:
|
|
110
|
+
return pointer.replace("~", "~0").replace("/", "~1")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def decode_pointer(value: str) -> str:
|
|
114
|
+
return value.replace("~1", "/").replace("~0", "~")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def iter_decoded_pointer_segments(pointer: str) -> Iterator[str]:
|
|
118
|
+
return map(decode_pointer, pointer.split("/")[1:])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
|
|
122
|
+
"""Implementation is adapted from Rust's `serde-json` crate.
|
|
123
|
+
|
|
124
|
+
Ref: https://github.com/serde-rs/json/blob/master/src/value/mod.rs#L751
|
|
125
|
+
"""
|
|
126
|
+
if not pointer:
|
|
127
|
+
return document
|
|
128
|
+
if not pointer.startswith("/"):
|
|
129
|
+
return UNRESOLVABLE
|
|
130
|
+
|
|
131
|
+
target = document
|
|
132
|
+
for token in iter_decoded_pointer_segments(pointer):
|
|
133
|
+
if isinstance(target, dict):
|
|
134
|
+
target = target.get(token, UNRESOLVABLE)
|
|
135
|
+
if target is UNRESOLVABLE:
|
|
136
|
+
return UNRESOLVABLE
|
|
137
|
+
elif isinstance(target, list):
|
|
138
|
+
try:
|
|
139
|
+
target = target[int(token)]
|
|
140
|
+
except (IndexError, ValueError):
|
|
141
|
+
return UNRESOLVABLE
|
|
142
|
+
else:
|
|
143
|
+
return UNRESOLVABLE
|
|
144
|
+
return target
|