opencode-pyneruntime 6.6.4__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.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- pynecore/utils/stdlib_checker.py +17 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typer Command subclass that supports dynamic parameter injection by plugins.
|
|
3
|
+
|
|
4
|
+
Typer builds the command tree from its own ``TyperCommand``/``TyperOption``
|
|
5
|
+
classes. By passing ``cls=PluggableCommand`` to ``@app.command()``, plugins
|
|
6
|
+
can register extra ``--flags`` that appear in ``--help`` and are parsed
|
|
7
|
+
alongside built-in parameters.
|
|
8
|
+
|
|
9
|
+
Plugins describe their options with :class:`~pynecore.core.plugin.CLIOption`
|
|
10
|
+
and this module turns them into the parser objects Typer expects. Plugins
|
|
11
|
+
never construct those objects themselves: Click is not a PyneCore dependency,
|
|
12
|
+
and since Typer 0.26 it is not a Typer dependency either — Typer vendors a
|
|
13
|
+
reduced fork of it, whose parser cannot handle foreign ``click.Option``
|
|
14
|
+
instances.
|
|
15
|
+
|
|
16
|
+
Plugin parameters are separated from core parameters before the callback is
|
|
17
|
+
invoked, so the original function signature does not need to change. The
|
|
18
|
+
injected values are stored on ``ctx.plugin_params``.
|
|
19
|
+
|
|
20
|
+
Typer rebuilds the whole command tree on every invocation
|
|
21
|
+
(``typer.main.get_command`` is not cached), so registrations cannot live on a
|
|
22
|
+
single command instance — they would be lost the next time the tree is built.
|
|
23
|
+
The registry is therefore class-level, keyed by command name, and every rebuilt
|
|
24
|
+
:class:`PluggableCommand` reads its injected parameters back from it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from typing import Any, TypeAlias
|
|
28
|
+
|
|
29
|
+
from typer.core import TyperCommand, TyperOption
|
|
30
|
+
# Typer has no public choice type; ``typer.main`` only re-exports this one
|
|
31
|
+
# noinspection PyProtectedMember
|
|
32
|
+
from typer._types import TyperChoice
|
|
33
|
+
|
|
34
|
+
from ..core.plugin import CLIOption
|
|
35
|
+
|
|
36
|
+
__all__ = ['PluggableCommand']
|
|
37
|
+
|
|
38
|
+
# The parser context class is Typer-version dependent — Click's own before
|
|
39
|
+
# Typer 0.26, Typer's vendored fork from 0.26 on — so it cannot be named
|
|
40
|
+
# portably here.
|
|
41
|
+
Context: TypeAlias = Any
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_option(spec: CLIOption) -> TyperOption:
|
|
45
|
+
"""
|
|
46
|
+
Build the parser object Typer expects from a backend-agnostic option spec.
|
|
47
|
+
|
|
48
|
+
:param spec: The plugin-provided option description.
|
|
49
|
+
:return: A Typer option ready to be added to a command.
|
|
50
|
+
"""
|
|
51
|
+
param_type: Any = TyperChoice(list(spec.choices)) if spec.choices is not None else spec.type
|
|
52
|
+
return TyperOption(
|
|
53
|
+
param_decls=list(spec.decls),
|
|
54
|
+
help=spec.help or None,
|
|
55
|
+
default=spec.default,
|
|
56
|
+
# ``None`` leaves the flag/value decision to Typer's own inference
|
|
57
|
+
is_flag=spec.is_flag or None,
|
|
58
|
+
type=param_type,
|
|
59
|
+
metavar=spec.metavar,
|
|
60
|
+
required=spec.required,
|
|
61
|
+
multiple=spec.multiple,
|
|
62
|
+
hidden=spec.hidden,
|
|
63
|
+
envvar=spec.envvar,
|
|
64
|
+
rich_help_panel=spec.rich_help_panel,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PluggableCommand(TyperCommand):
|
|
69
|
+
"""
|
|
70
|
+
A Typer command that allows plugins to inject parameters.
|
|
71
|
+
|
|
72
|
+
Usage::
|
|
73
|
+
|
|
74
|
+
@app.command(cls=PluggableCommand)
|
|
75
|
+
def run(ctx: typer.Context, script: Path = ...):
|
|
76
|
+
live = ctx.plugin_params.get('live', False)
|
|
77
|
+
|
|
78
|
+
After the command is registered, call :meth:`register_plugin_param` to add
|
|
79
|
+
plugin-provided options.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# Command-name -> injected params. Class-level so it survives Typer
|
|
83
|
+
# rebuilding the command tree on each invocation. Command names are unique
|
|
84
|
+
# leaves (e.g. "run", "download"), so the leaf name is a safe key.
|
|
85
|
+
_plugin_param_registry: dict[str, list[TyperOption]] = {}
|
|
86
|
+
|
|
87
|
+
def register_plugin_param(self, spec: CLIOption) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Register a plugin-provided parameter for this command.
|
|
90
|
+
|
|
91
|
+
Checks both parameter names and option strings (e.g. ``--from``, ``-f``)
|
|
92
|
+
against the core parameters and already-registered plugin parameters to
|
|
93
|
+
prevent conflicts.
|
|
94
|
+
|
|
95
|
+
:param spec: The option to inject.
|
|
96
|
+
:return: ``False`` if the name or any option string conflicts.
|
|
97
|
+
"""
|
|
98
|
+
param = _build_option(spec)
|
|
99
|
+
registered = self._plugin_param_registry.setdefault(self.name or "", [])
|
|
100
|
+
all_params = [*self.params, *registered]
|
|
101
|
+
|
|
102
|
+
existing_names = {p.name for p in all_params}
|
|
103
|
+
if param.name in existing_names:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
existing_opts = {opt for p in all_params for opt in getattr(p, 'opts', ())}
|
|
107
|
+
new_opts = set(getattr(param, 'opts', ()))
|
|
108
|
+
if existing_opts & new_opts:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
registered.append(param)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def _plugin_params(self) -> list[TyperOption]:
|
|
115
|
+
"""Injected parameters registered for this command name."""
|
|
116
|
+
return self._plugin_param_registry.get(self.name or "", [])
|
|
117
|
+
|
|
118
|
+
def get_params(self, ctx: Context) -> list[Any]:
|
|
119
|
+
"""Return core params + plugin params + help option."""
|
|
120
|
+
rv = [*self.params, *self._plugin_params()]
|
|
121
|
+
help_option = self.get_help_option(ctx)
|
|
122
|
+
if help_option is not None:
|
|
123
|
+
rv.append(help_option)
|
|
124
|
+
return rv
|
|
125
|
+
|
|
126
|
+
def invoke(self, ctx: Context) -> None:
|
|
127
|
+
"""Pop plugin params from ctx.params before calling the callback."""
|
|
128
|
+
ctx.plugin_params = {}
|
|
129
|
+
for p in self._plugin_params():
|
|
130
|
+
if p.name in ctx.params:
|
|
131
|
+
ctx.plugin_params[p.name] = ctx.params.pop(p.name)
|
|
132
|
+
return super().invoke(ctx)
|
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Centralized API error handling utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from typer import Exit
|
|
7
|
+
|
|
8
|
+
from pynecore.pynesys.api import APIError, AuthError, RateLimitError, CompilationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIErrorHandler:
|
|
12
|
+
"""Context manager that provides centralized API error handling."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, console: Console | None = None):
|
|
15
|
+
self.console: Console = console or Console()
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
21
|
+
if exc_type is None:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
if exc_type == CompilationError:
|
|
25
|
+
self._handle_compilation_error(exc_value)
|
|
26
|
+
elif exc_type == AuthError:
|
|
27
|
+
self._handle_auth_error(exc_value)
|
|
28
|
+
elif exc_type == RateLimitError:
|
|
29
|
+
self._handle_rate_limit_error(exc_value)
|
|
30
|
+
elif exc_type == APIError:
|
|
31
|
+
self._handle_api_error(exc_value)
|
|
32
|
+
else:
|
|
33
|
+
return False # Let other exceptions propagate
|
|
34
|
+
|
|
35
|
+
raise Exit(1)
|
|
36
|
+
|
|
37
|
+
def _handle_compilation_error(self, e: CompilationError):
|
|
38
|
+
"""Handle compilation-specific errors."""
|
|
39
|
+
self.console.print(f"[red]Oops! Compilation encountered an issue:[/red] {str(e)}")
|
|
40
|
+
if e.validation_errors:
|
|
41
|
+
self.console.print("[red]Validation errors:[/red]")
|
|
42
|
+
for error in e.validation_errors:
|
|
43
|
+
self.console.print(f" [red]• {error}[/red]")
|
|
44
|
+
|
|
45
|
+
def _handle_auth_error(self, e: AuthError):
|
|
46
|
+
"""Handle authentication errors."""
|
|
47
|
+
self.console.print(f"[red]Authentication issue:[/red] {str(e)}")
|
|
48
|
+
self.console.print("[yellow]To fix:[/yellow] Check [cyan]api_key[/cyan] in [cyan]api.toml[/cyan] "
|
|
49
|
+
"in your working directory")
|
|
50
|
+
|
|
51
|
+
def _handle_rate_limit_error(self, e: RateLimitError):
|
|
52
|
+
"""Handle rate limit errors."""
|
|
53
|
+
self.console.print("[red]Rate Limit Exceeded:[/red] You've hit your compilation limit")
|
|
54
|
+
if e.retry_after:
|
|
55
|
+
self.console.print(f"[yellow]Please try again in {e.retry_after} seconds[/yellow]")
|
|
56
|
+
self.console.print(
|
|
57
|
+
"[yellow]To increase your limits, consider upgrading your subscription at "
|
|
58
|
+
"[link=https://pynesys.io]https://pynesys.io[/link]")
|
|
59
|
+
|
|
60
|
+
def _handle_api_error(self, e: APIError):
|
|
61
|
+
"""Handle general API errors with specific status code handling."""
|
|
62
|
+
error_msg = str(e).lower()
|
|
63
|
+
|
|
64
|
+
# Handle specific API error scenarios based on HTTP status codes
|
|
65
|
+
has_400_error = "400" in error_msg or "bad request" in error_msg
|
|
66
|
+
has_structured_error = (
|
|
67
|
+
'"detail":' in error_msg and
|
|
68
|
+
'"error":' in error_msg and
|
|
69
|
+
'"line":' in error_msg
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if has_400_error or has_structured_error:
|
|
73
|
+
if "compilation fails" in error_msg or "script is too large" in error_msg:
|
|
74
|
+
self.console.print("[red]Script Issue:[/red] Your Pine Script couldn't be compiled")
|
|
75
|
+
self.console.print("[yellow]Common fixes:[/yellow]")
|
|
76
|
+
self.console.print(" • Check if your script is too large (try breaking it into smaller parts)")
|
|
77
|
+
self.console.print(" • Verify your Pine Script syntax is correct")
|
|
78
|
+
self.console.print(" • Make sure you're using Pine Script v6 syntax")
|
|
79
|
+
else:
|
|
80
|
+
# Try to parse structured error response (JSON-like format)
|
|
81
|
+
error_str = str(e)
|
|
82
|
+
structured_error_parsed = False
|
|
83
|
+
|
|
84
|
+
# Check for structured JSON error format
|
|
85
|
+
has_error_and_line = (
|
|
86
|
+
("'error':" in error_str and "'line':" in error_str) or
|
|
87
|
+
('"error":' in error_str and '"line":' in error_str)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if has_error_and_line:
|
|
91
|
+
# Pattern for JSON format: {"detail":{"error":"...","line":...,"file":"..."}}
|
|
92
|
+
json_pattern = (
|
|
93
|
+
r'\{"detail":\{"status":"error","error":"([^"]+)",' +
|
|
94
|
+
r'"line":(\d+),"file":"([^"]+)"\}\}'
|
|
95
|
+
)
|
|
96
|
+
match = re.search(json_pattern, error_str)
|
|
97
|
+
if match:
|
|
98
|
+
error_msg, line_num, file_name = match.groups()
|
|
99
|
+
self.console.print(f"[red]Script Error:[/red] {error_msg}")
|
|
100
|
+
self.console.print(
|
|
101
|
+
f"[yellow]Location:[/yellow] Line {line_num} in {file_name}"
|
|
102
|
+
)
|
|
103
|
+
self.console.print(
|
|
104
|
+
"[yellow]Quick fix:[/yellow] Check the variable declaration and spelling"
|
|
105
|
+
)
|
|
106
|
+
structured_error_parsed = True
|
|
107
|
+
|
|
108
|
+
# Fallback to generic error display if structured parsing failed
|
|
109
|
+
if not structured_error_parsed:
|
|
110
|
+
self.console.print(f"[red]Script Error:[/red] {str(e)}")
|
|
111
|
+
self.console.print("[yellow]Common causes:[/yellow]")
|
|
112
|
+
self.console.print(" • Pine Script syntax errors")
|
|
113
|
+
self.console.print(" • Unsupported Pine Script features")
|
|
114
|
+
self.console.print(" • Incorrect variable declarations or usage")
|
|
115
|
+
|
|
116
|
+
elif "401" in error_msg or "authentication" in error_msg or "no permission" in error_msg:
|
|
117
|
+
self.console.print("[red]Authentication Failed:[/red] Your API credentials aren't working")
|
|
118
|
+
self.console.print("[yellow]Quick fixes:[/yellow]")
|
|
119
|
+
self.console.print(" • Check if your API key is valid and active")
|
|
120
|
+
self.console.print(" • Verify your token type is allowed for compilation")
|
|
121
|
+
self.console.print(
|
|
122
|
+
"Get a new API key at [link=https://pynesys.io]https://pynesys.io[/link]")
|
|
123
|
+
self.console.print(
|
|
124
|
+
"Then run [cyan]'pyne api configure'[/cyan] to update your configuration")
|
|
125
|
+
|
|
126
|
+
elif "404" in error_msg or "not found" in error_msg:
|
|
127
|
+
self.console.print("[red]Not Found:[/red] The API endpoint or user wasn't found")
|
|
128
|
+
self.console.print("[yellow]This might indicate:[/yellow]")
|
|
129
|
+
self.console.print(" • Your account may not exist or be accessible")
|
|
130
|
+
self.console.print(" • There might be a temporary service issue")
|
|
131
|
+
|
|
132
|
+
elif "422" in error_msg or "validation error" in error_msg:
|
|
133
|
+
self.console.print("[red]Validation Error:[/red] Your request data has validation issues")
|
|
134
|
+
self.console.print("[yellow]Common causes:[/yellow]")
|
|
135
|
+
self.console.print(" • Invalid Pine Script syntax or structure")
|
|
136
|
+
self.console.print(" • Missing required parameters")
|
|
137
|
+
self.console.print(" • Incorrect data format")
|
|
138
|
+
self.console.print(f"[dim]Details: {str(e)}[/dim]")
|
|
139
|
+
|
|
140
|
+
elif "429" in error_msg or "rate limit" in error_msg or "too many requests" in error_msg:
|
|
141
|
+
self.console.print("[red]Rate Limit Exceeded:[/red] You've hit your compilation limit")
|
|
142
|
+
self.console.print("[yellow]What you can do:[/yellow]")
|
|
143
|
+
self.console.print(" • Wait a bit before trying again")
|
|
144
|
+
self.console.print(" • Consider upgrading your plan for higher limits")
|
|
145
|
+
|
|
146
|
+
elif "500" in error_msg or "server" in error_msg or "internal" in error_msg:
|
|
147
|
+
self.console.print("[red]Server Error:[/red] Something went wrong on our end")
|
|
148
|
+
self.console.print(" • This is a temporary server issue")
|
|
149
|
+
self.console.print(" • Please try again in a few moments")
|
|
150
|
+
|
|
151
|
+
elif "unsupported pinescript version" in error_msg:
|
|
152
|
+
self.console.print("[red]Version Issue:[/red] Your Pine Script version isn't supported")
|
|
153
|
+
if "version 5" in error_msg:
|
|
154
|
+
self.console.print("[yellow]Pine Script v5 → v6 Migration:[/yellow]")
|
|
155
|
+
self.console.print(" • Update your script to Pine Script version 6")
|
|
156
|
+
self.console.print(" • Most v5 scripts need minimal changes")
|
|
157
|
+
else:
|
|
158
|
+
self.console.print("[yellow]Only Pine Script version 6 is currently supported[/yellow]")
|
|
159
|
+
|
|
160
|
+
elif "api key" in error_msg:
|
|
161
|
+
self.console.print("[red]API Key Issue:[/red] There's a problem with your API key")
|
|
162
|
+
self.console.print("Get your API key at [link=https://pynesys.io]https://pynesys.io[/link]")
|
|
163
|
+
self.console.print(
|
|
164
|
+
"Then run [cyan]'pyne api configure'[/cyan] to set up your configuration")
|
|
165
|
+
|
|
166
|
+
else:
|
|
167
|
+
# Generic API error fallback
|
|
168
|
+
self.console.print(f"[red]API Error:[/red] {str(e)}")
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive broker / exchange picker TUI for multi-broker data providers.
|
|
3
|
+
|
|
4
|
+
Multi-broker providers (CCXT serves ~100 crypto exchanges, cTrader serves every
|
|
5
|
+
broker the user holds an account with) need a backend chosen before a symbol can
|
|
6
|
+
be browsed. This module renders a single-pane, filterable, scrollable list on the
|
|
7
|
+
alternate screen buffer via ``rich.live`` and returns the chosen broker id, or
|
|
8
|
+
``None`` if the user quits without picking.
|
|
9
|
+
|
|
10
|
+
Navigation mirrors :class:`~pynecore.cli.utils.symbol_browser.SymbolBrowser`:
|
|
11
|
+
arrow keys / PgUp / PgDn / Home / End move the cursor, ``/`` starts a substring
|
|
12
|
+
filter, ENTER selects, ``q`` / ESC quits.
|
|
13
|
+
"""
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Protocol, Sequence
|
|
18
|
+
|
|
19
|
+
from rich.console import Console, Group
|
|
20
|
+
from rich.layout import Layout
|
|
21
|
+
from rich.live import Live
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from .keyreader import Key, KeyOrChar, raw_terminal, read_key
|
|
26
|
+
|
|
27
|
+
# Footer block is one line of help text inside a Panel (2 border lines).
|
|
28
|
+
_FOOTER_HEIGHT = 3
|
|
29
|
+
|
|
30
|
+
# Panel top + bottom border lines deducted from the list panel height.
|
|
31
|
+
_LIST_CHROME_LINES = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PickerItem(Protocol):
|
|
35
|
+
"""Structural type for an item shown by :class:`BrokerPicker`."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def id(self) -> str:
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BrokerPicker:
|
|
47
|
+
"""Single-pane filterable list of broker / exchange ids.
|
|
48
|
+
|
|
49
|
+
Each row shows the id selector and, when the provider supplies one, a
|
|
50
|
+
human-readable name in a second column. Filtering matches either column;
|
|
51
|
+
selection returns the chosen broker's id.
|
|
52
|
+
|
|
53
|
+
:ivar selected: The chosen broker id once :meth:`run` returns, else ``None``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, brokers: Sequence[PickerItem], *, provider_name: str,
|
|
57
|
+
item_name: str = "brokers"):
|
|
58
|
+
"""
|
|
59
|
+
:param brokers: The brokers / exchanges to choose from.
|
|
60
|
+
:param provider_name: Provider name shown in the panel title.
|
|
61
|
+
:param item_name: Plural name shown after ``provider_name`` in the title.
|
|
62
|
+
"""
|
|
63
|
+
self.brokers: list[PickerItem] = list(brokers)
|
|
64
|
+
self.provider_name = provider_name
|
|
65
|
+
self.item_name = item_name
|
|
66
|
+
|
|
67
|
+
# View state.
|
|
68
|
+
self.filtered: list[PickerItem] = list(self.brokers)
|
|
69
|
+
self.cursor: int = 0
|
|
70
|
+
self.scroll_offset: int = 0
|
|
71
|
+
self.filter_text: str = ''
|
|
72
|
+
self.filter_active: bool = False
|
|
73
|
+
|
|
74
|
+
# Result.
|
|
75
|
+
self.selected: str | None = None
|
|
76
|
+
|
|
77
|
+
# Optional error shown above the help footer. The caller sets it before
|
|
78
|
+
# re-running the picker when the chosen broker could not be opened (e.g.
|
|
79
|
+
# an exchange that needs API credentials), so the user can pick another
|
|
80
|
+
# without the command exiting.
|
|
81
|
+
self.error: str | None = None
|
|
82
|
+
|
|
83
|
+
# Resize state (Unix uses SIGWINCH; Windows polls size).
|
|
84
|
+
self.resize_event: threading.Event = threading.Event()
|
|
85
|
+
self.last_size: object = shutil.get_terminal_size()
|
|
86
|
+
self._old_sigwinch = None
|
|
87
|
+
|
|
88
|
+
# ---- filter + navigation -----------------------------------------
|
|
89
|
+
|
|
90
|
+
def _apply_filter(self) -> None:
|
|
91
|
+
if self.filter_text:
|
|
92
|
+
needle = self.filter_text.lower()
|
|
93
|
+
self.filtered = [b for b in self.brokers
|
|
94
|
+
if needle in b.id.lower() or needle in b.name.lower()]
|
|
95
|
+
else:
|
|
96
|
+
self.filtered = list(self.brokers)
|
|
97
|
+
if self.cursor >= len(self.filtered):
|
|
98
|
+
self.cursor = max(0, len(self.filtered) - 1)
|
|
99
|
+
self.scroll_offset = 0
|
|
100
|
+
|
|
101
|
+
def _move_cursor(self, delta: int) -> None:
|
|
102
|
+
if not self.filtered:
|
|
103
|
+
self.cursor = 0
|
|
104
|
+
return
|
|
105
|
+
self.cursor = max(0, min(len(self.filtered) - 1, self.cursor + delta))
|
|
106
|
+
|
|
107
|
+
def _ensure_cursor_visible(self, list_height: int) -> None:
|
|
108
|
+
if list_height <= 0:
|
|
109
|
+
return
|
|
110
|
+
if self.cursor < self.scroll_offset:
|
|
111
|
+
self.scroll_offset = self.cursor
|
|
112
|
+
elif self.cursor >= self.scroll_offset + list_height:
|
|
113
|
+
self.scroll_offset = self.cursor - list_height + 1
|
|
114
|
+
|
|
115
|
+
# ---- key handling -------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _handle_key(self, key: KeyOrChar) -> bool:
|
|
118
|
+
"""Return False to request exit, True to continue."""
|
|
119
|
+
prev_cursor = self.cursor
|
|
120
|
+
prev_filter = self.filter_text
|
|
121
|
+
if self.filter_active:
|
|
122
|
+
result = self._handle_filter_key(key)
|
|
123
|
+
else:
|
|
124
|
+
result = self._handle_normal_key(key)
|
|
125
|
+
# A broker-open error belongs to the row it was raised on; once the user
|
|
126
|
+
# moves the highlight or changes the filter it is stale and misleading,
|
|
127
|
+
# so clear it as soon as the view selection changes.
|
|
128
|
+
if self.cursor != prev_cursor or self.filter_text != prev_filter:
|
|
129
|
+
self.error = None
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def _select_current(self) -> bool:
|
|
133
|
+
"""Commit the highlighted broker's id and request exit."""
|
|
134
|
+
if self.filtered:
|
|
135
|
+
self.selected = self.filtered[self.cursor].id
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def _handle_filter_key(self, key: KeyOrChar) -> bool:
|
|
139
|
+
if key is Key.ESC:
|
|
140
|
+
self.filter_active = False
|
|
141
|
+
if self.filter_text:
|
|
142
|
+
self.filter_text = ''
|
|
143
|
+
self._apply_filter()
|
|
144
|
+
return True
|
|
145
|
+
if key is Key.ENTER:
|
|
146
|
+
self.filter_active = False
|
|
147
|
+
return self._select_current()
|
|
148
|
+
if key is Key.BACKSPACE:
|
|
149
|
+
if self.filter_text:
|
|
150
|
+
self.filter_text = self.filter_text[:-1]
|
|
151
|
+
self._apply_filter()
|
|
152
|
+
else:
|
|
153
|
+
self.filter_active = False
|
|
154
|
+
return True
|
|
155
|
+
# Navigation keys exit the filter (keeping the typed text as the active
|
|
156
|
+
# list filter) and move the cursor, mirroring the symbol browser.
|
|
157
|
+
if key is Key.UP:
|
|
158
|
+
self.filter_active = False
|
|
159
|
+
self._move_cursor(-1)
|
|
160
|
+
return True
|
|
161
|
+
if key is Key.DOWN:
|
|
162
|
+
self.filter_active = False
|
|
163
|
+
self._move_cursor(1)
|
|
164
|
+
return True
|
|
165
|
+
if key is Key.PAGE_UP:
|
|
166
|
+
self.filter_active = False
|
|
167
|
+
self._move_cursor(-10)
|
|
168
|
+
return True
|
|
169
|
+
if key is Key.PAGE_DOWN:
|
|
170
|
+
self.filter_active = False
|
|
171
|
+
self._move_cursor(10)
|
|
172
|
+
return True
|
|
173
|
+
if isinstance(key, str) and key.isprintable():
|
|
174
|
+
self.filter_text += key
|
|
175
|
+
self._apply_filter()
|
|
176
|
+
return True
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def _handle_normal_key(self, key: KeyOrChar) -> bool:
|
|
180
|
+
if isinstance(key, str):
|
|
181
|
+
if key == 'q':
|
|
182
|
+
return False
|
|
183
|
+
if key == '/':
|
|
184
|
+
self.filter_active = True
|
|
185
|
+
return True
|
|
186
|
+
if key is Key.ESC:
|
|
187
|
+
return False
|
|
188
|
+
if key is Key.ENTER:
|
|
189
|
+
return self._select_current()
|
|
190
|
+
if key is Key.UP:
|
|
191
|
+
self._move_cursor(-1)
|
|
192
|
+
elif key is Key.DOWN:
|
|
193
|
+
self._move_cursor(1)
|
|
194
|
+
elif key is Key.PAGE_UP:
|
|
195
|
+
self._move_cursor(-10)
|
|
196
|
+
elif key is Key.PAGE_DOWN:
|
|
197
|
+
self._move_cursor(10)
|
|
198
|
+
elif key is Key.HOME:
|
|
199
|
+
self.cursor = 0
|
|
200
|
+
elif key is Key.END:
|
|
201
|
+
self.cursor = max(0, len(self.filtered) - 1)
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
# ---- rendering ----------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _render_list(self, height: int) -> Panel:
|
|
207
|
+
list_height = max(1, height - _LIST_CHROME_LINES)
|
|
208
|
+
self._ensure_cursor_visible(list_height)
|
|
209
|
+
end = self.scroll_offset + list_height
|
|
210
|
+
visible = self.filtered[self.scroll_offset:end]
|
|
211
|
+
# Align the name column to the widest id in the full list so it stays
|
|
212
|
+
# put while scrolling/filtering; only pad when at least one name exists.
|
|
213
|
+
id_width = max((len(b.id) for b in self.brokers), default=0)
|
|
214
|
+
has_names = any(b.name for b in self.brokers)
|
|
215
|
+
lines: list[Text] = []
|
|
216
|
+
for i, broker in enumerate(visible):
|
|
217
|
+
idx = self.scroll_offset + i
|
|
218
|
+
if has_names:
|
|
219
|
+
label = f"{broker.id:<{id_width}} {broker.name}".rstrip()
|
|
220
|
+
else:
|
|
221
|
+
label = broker.id
|
|
222
|
+
if idx == self.cursor:
|
|
223
|
+
lines.append(Text(f"> {label}", style="bold reverse"))
|
|
224
|
+
else:
|
|
225
|
+
lines.append(Text(f" {label}"))
|
|
226
|
+
if not lines:
|
|
227
|
+
lines.append(Text(" (no matches)", style="dim"))
|
|
228
|
+
title_parts = [f"{self.provider_name} {self.item_name} "
|
|
229
|
+
f"({len(self.filtered)}/{len(self.brokers)})"]
|
|
230
|
+
if self.filter_active or self.filter_text:
|
|
231
|
+
cursor_marker = "_" if self.filter_active else ""
|
|
232
|
+
title_parts.append(f"/{self.filter_text}{cursor_marker}")
|
|
233
|
+
title = " ".join(title_parts)
|
|
234
|
+
return Panel(Group(*lines), title=title, title_align="left")
|
|
235
|
+
|
|
236
|
+
def _footer_height(self) -> int:
|
|
237
|
+
"""Footer grows by one row when an error line is shown."""
|
|
238
|
+
return _FOOTER_HEIGHT + (1 if self.error else 0)
|
|
239
|
+
|
|
240
|
+
def _render_footer(self) -> Panel:
|
|
241
|
+
help_text = Text(
|
|
242
|
+
"Up Down: navigate - PgUp PgDn: jump 10 - /: search - "
|
|
243
|
+
"Enter: select - q: quit",
|
|
244
|
+
style="dim",
|
|
245
|
+
)
|
|
246
|
+
if self.error:
|
|
247
|
+
return Panel(Group(Text(self.error, style="red"), help_text),
|
|
248
|
+
height=self._footer_height())
|
|
249
|
+
return Panel(help_text, height=_FOOTER_HEIGHT)
|
|
250
|
+
|
|
251
|
+
def _build_layout(self, console: Console) -> Layout:
|
|
252
|
+
footer_height = self._footer_height()
|
|
253
|
+
height = max(footer_height + 3, console.size.height)
|
|
254
|
+
list_height = height - footer_height
|
|
255
|
+
layout = Layout()
|
|
256
|
+
layout.split_column(
|
|
257
|
+
Layout(self._render_list(list_height), name="list"),
|
|
258
|
+
Layout(self._render_footer(), name="footer", size=footer_height),
|
|
259
|
+
)
|
|
260
|
+
return layout
|
|
261
|
+
|
|
262
|
+
# ---- resize handling ---------------------------------------------
|
|
263
|
+
|
|
264
|
+
def _install_sigwinch(self) -> None:
|
|
265
|
+
if sys.platform == 'win32':
|
|
266
|
+
return
|
|
267
|
+
import signal
|
|
268
|
+
self._old_sigwinch = signal.signal(
|
|
269
|
+
signal.SIGWINCH, lambda *_: self.resize_event.set()
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _restore_sigwinch(self) -> None:
|
|
273
|
+
if sys.platform == 'win32' or self._old_sigwinch is None:
|
|
274
|
+
return
|
|
275
|
+
import signal
|
|
276
|
+
signal.signal(signal.SIGWINCH, self._old_sigwinch)
|
|
277
|
+
self._old_sigwinch = None
|
|
278
|
+
|
|
279
|
+
def _check_size_change(self) -> bool:
|
|
280
|
+
"""Windows fallback: detect resize by polling terminal size."""
|
|
281
|
+
if sys.platform != 'win32':
|
|
282
|
+
return False
|
|
283
|
+
current = shutil.get_terminal_size()
|
|
284
|
+
if current != self.last_size:
|
|
285
|
+
self.last_size = current
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
# ---- run loop -----------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def run(self) -> str | None:
|
|
292
|
+
"""Render the picker and block until the user selects or quits.
|
|
293
|
+
|
|
294
|
+
:return: The chosen broker id, or ``None`` if the user quit.
|
|
295
|
+
"""
|
|
296
|
+
if not self.brokers:
|
|
297
|
+
print(f"No {self.item_name} available.", file=sys.stderr)
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
# Reset so the picker can be re-run after a failed broker attempt.
|
|
301
|
+
self.selected = None
|
|
302
|
+
|
|
303
|
+
console = Console()
|
|
304
|
+
self._install_sigwinch()
|
|
305
|
+
try:
|
|
306
|
+
with raw_terminal():
|
|
307
|
+
with Live(
|
|
308
|
+
self._build_layout(console),
|
|
309
|
+
console=console,
|
|
310
|
+
screen=True,
|
|
311
|
+
auto_refresh=False,
|
|
312
|
+
) as live:
|
|
313
|
+
self._main_loop(live, console)
|
|
314
|
+
except KeyboardInterrupt:
|
|
315
|
+
return None
|
|
316
|
+
finally:
|
|
317
|
+
self._restore_sigwinch()
|
|
318
|
+
return self.selected
|
|
319
|
+
|
|
320
|
+
def _main_loop(self, live: Live, console: Console) -> None:
|
|
321
|
+
while True:
|
|
322
|
+
key = read_key(timeout=0.05)
|
|
323
|
+
if key is not None:
|
|
324
|
+
if not self._handle_key(key):
|
|
325
|
+
return
|
|
326
|
+
if self.resize_event.is_set():
|
|
327
|
+
self.resize_event.clear()
|
|
328
|
+
self._check_size_change()
|
|
329
|
+
live.update(self._build_layout(console))
|
|
330
|
+
live.refresh()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import traceback
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def setup_global_error_logging(log_path: Path):
|
|
7
|
+
"""
|
|
8
|
+
Sets up a global error logging mechanism that writes uncaught exceptions to a logfile.
|
|
9
|
+
|
|
10
|
+
:param log_path: Path to the log file where uncaught exceptions will be written.
|
|
11
|
+
"""
|
|
12
|
+
# Creating the log file directory
|
|
13
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
# Remove last error file if exists
|
|
15
|
+
if log_path.exists():
|
|
16
|
+
log_path.unlink()
|
|
17
|
+
|
|
18
|
+
# Save the original excepthook
|
|
19
|
+
original_excepthook = sys.excepthook
|
|
20
|
+
|
|
21
|
+
# Exception handler – saves the raw stack trace to a file
|
|
22
|
+
def log_and_reraise(exc_type, exc_value, tb):
|
|
23
|
+
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, tb))
|
|
24
|
+
log_path.write_text(tb_str, encoding="utf-8")
|
|
25
|
+
original_excepthook(exc_type, exc_value, tb)
|
|
26
|
+
|
|
27
|
+
# Set the new excepthook
|
|
28
|
+
sys.excepthook = log_and_reraise
|