tescmd 0.1.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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/models/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, field_validator
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Profile(BaseModel):
|
|
8
|
+
"""A named profile grouping common CLI settings."""
|
|
9
|
+
|
|
10
|
+
region: str = "na"
|
|
11
|
+
vin: str | None = None
|
|
12
|
+
output_format: str | None = None
|
|
13
|
+
client_id: str | None = None
|
|
14
|
+
client_secret: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppSettings(BaseSettings):
|
|
18
|
+
"""Application-wide settings populated from environment variables and .env file."""
|
|
19
|
+
|
|
20
|
+
model_config = SettingsConfigDict(
|
|
21
|
+
env_file=".env",
|
|
22
|
+
env_prefix="TESLA_",
|
|
23
|
+
extra="ignore",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
client_id: str | None = None
|
|
27
|
+
client_secret: str | None = None
|
|
28
|
+
domain: str | None = None
|
|
29
|
+
vin: str | None = None
|
|
30
|
+
|
|
31
|
+
@field_validator("domain", mode="before")
|
|
32
|
+
@classmethod
|
|
33
|
+
def _lowercase_domain(cls, v: str | None) -> str | None:
|
|
34
|
+
"""Tesla Fleet API rejects domains with uppercase characters."""
|
|
35
|
+
if v is not None:
|
|
36
|
+
return v.lower()
|
|
37
|
+
return v
|
|
38
|
+
|
|
39
|
+
region: str = "na"
|
|
40
|
+
token_file: str | None = None
|
|
41
|
+
config_dir: str = "~/.config/tescmd"
|
|
42
|
+
output_format: str | None = None
|
|
43
|
+
profile: str = "default"
|
|
44
|
+
setup_tier: str | None = None
|
|
45
|
+
github_repo: str | None = None
|
|
46
|
+
access_token: str | None = None
|
|
47
|
+
refresh_token: str | None = None
|
|
48
|
+
|
|
49
|
+
# Cache settings (TESLA_CACHE_ENABLED, TESLA_CACHE_TTL, TESLA_CACHE_DIR)
|
|
50
|
+
cache_enabled: bool = True
|
|
51
|
+
cache_ttl: int = 60
|
|
52
|
+
cache_dir: str = "~/.cache/tescmd"
|
|
53
|
+
|
|
54
|
+
# Command protocol: auto | signed | unsigned
|
|
55
|
+
# auto = use signed when keys available, fall back to unsigned
|
|
56
|
+
# signed = require signed (error if no keys)
|
|
57
|
+
# unsigned = force legacy REST path
|
|
58
|
+
command_protocol: str = "auto"
|
|
59
|
+
|
|
60
|
+
# Display units (TESLA_TEMP_UNIT, TESLA_DISTANCE_UNIT, TESLA_PRESSURE_UNIT)
|
|
61
|
+
temp_unit: str = "F"
|
|
62
|
+
distance_unit: str = "mi"
|
|
63
|
+
pressure_unit: str = "psi"
|
tescmd/models/energy.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pydantic models for Tesla energy products (Powerwall, Solar)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
_EXTRA_ALLOW = ConfigDict(extra="allow")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnergySite(BaseModel):
|
|
13
|
+
model_config = _EXTRA_ALLOW
|
|
14
|
+
|
|
15
|
+
energy_site_id: int
|
|
16
|
+
resource_type: str | None = None
|
|
17
|
+
site_name: str | None = None
|
|
18
|
+
gateway_id: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LiveStatus(BaseModel):
|
|
22
|
+
model_config = _EXTRA_ALLOW
|
|
23
|
+
|
|
24
|
+
solar_power: float | None = None
|
|
25
|
+
battery_power: float | None = None
|
|
26
|
+
grid_power: float | None = None
|
|
27
|
+
load_power: float | None = None
|
|
28
|
+
grid_status: str | None = None
|
|
29
|
+
battery_level: float | None = None
|
|
30
|
+
percentage_charged: float | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SiteInfo(BaseModel):
|
|
34
|
+
model_config = _EXTRA_ALLOW
|
|
35
|
+
|
|
36
|
+
energy_site_id: int | None = None
|
|
37
|
+
site_name: str | None = None
|
|
38
|
+
resource_type: str | None = None
|
|
39
|
+
backup_reserve_percent: float | None = None
|
|
40
|
+
default_real_mode: str | None = None
|
|
41
|
+
storm_mode_enabled: bool | None = None
|
|
42
|
+
installation_date: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CalendarHistory(BaseModel):
|
|
46
|
+
model_config = _EXTRA_ALLOW
|
|
47
|
+
|
|
48
|
+
serial_number: str | None = None
|
|
49
|
+
time_series: list[dict[str, Any]] = []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GridImportExportConfig(BaseModel):
|
|
53
|
+
model_config = _EXTRA_ALLOW
|
|
54
|
+
|
|
55
|
+
disallow_charge_from_grid_with_solar_installed: bool | None = None
|
|
56
|
+
customer_preferred_export_rule: str | None = None
|
tescmd/models/sharing.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Pydantic models for Tesla vehicle sharing (drivers and invites)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
_EXTRA_ALLOW = ConfigDict(extra="allow")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ShareDriverInfo(BaseModel):
|
|
11
|
+
model_config = _EXTRA_ALLOW
|
|
12
|
+
|
|
13
|
+
share_user_id: int | None = None
|
|
14
|
+
email: str | None = None
|
|
15
|
+
status: str | None = None
|
|
16
|
+
public_key: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ShareInvite(BaseModel):
|
|
20
|
+
model_config = _EXTRA_ALLOW
|
|
21
|
+
|
|
22
|
+
id: str | None = None
|
|
23
|
+
code: str | None = None
|
|
24
|
+
created_at: str | None = None
|
|
25
|
+
expires_at: str | None = None
|
|
26
|
+
status: str | None = None
|
tescmd/models/user.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pydantic models for Tesla user account data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
_EXTRA_ALLOW = ConfigDict(extra="allow")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UserInfo(BaseModel):
|
|
11
|
+
model_config = _EXTRA_ALLOW
|
|
12
|
+
|
|
13
|
+
email: str | None = None
|
|
14
|
+
full_name: str | None = None
|
|
15
|
+
profile_image_url: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserRegion(BaseModel):
|
|
19
|
+
model_config = _EXTRA_ALLOW
|
|
20
|
+
|
|
21
|
+
region: str | None = None
|
|
22
|
+
fleet_api_base_url: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VehicleOrder(BaseModel):
|
|
26
|
+
model_config = _EXTRA_ALLOW
|
|
27
|
+
|
|
28
|
+
order_id: str | None = None
|
|
29
|
+
vin: str | None = None
|
|
30
|
+
model: str | None = None
|
|
31
|
+
status: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FeatureConfig(BaseModel):
|
|
35
|
+
model_config = _EXTRA_ALLOW
|
|
36
|
+
|
|
37
|
+
signaling: dict[str, bool] | None = None
|
tescmd/models/vehicle.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
_EXTRA_ALLOW = ConfigDict(extra="allow")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Vehicle(BaseModel):
|
|
9
|
+
model_config = _EXTRA_ALLOW
|
|
10
|
+
|
|
11
|
+
vin: str
|
|
12
|
+
display_name: str | None = None
|
|
13
|
+
state: str = "unknown"
|
|
14
|
+
vehicle_id: int | None = None
|
|
15
|
+
access_type: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DriveState(BaseModel):
|
|
19
|
+
model_config = _EXTRA_ALLOW
|
|
20
|
+
|
|
21
|
+
latitude: float | None = None
|
|
22
|
+
longitude: float | None = None
|
|
23
|
+
heading: int | None = None
|
|
24
|
+
speed: int | None = None
|
|
25
|
+
power: int | None = None
|
|
26
|
+
shift_state: str | None = None
|
|
27
|
+
timestamp: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChargeState(BaseModel):
|
|
31
|
+
model_config = _EXTRA_ALLOW
|
|
32
|
+
|
|
33
|
+
battery_level: int | None = None
|
|
34
|
+
battery_range: float | None = None
|
|
35
|
+
charge_limit_soc: int | None = None
|
|
36
|
+
charging_state: str | None = None
|
|
37
|
+
charge_rate: float | None = None
|
|
38
|
+
charger_voltage: int | None = None
|
|
39
|
+
charger_actual_current: int | None = None
|
|
40
|
+
charge_port_door_open: bool | None = None
|
|
41
|
+
minutes_to_full_charge: int | None = None
|
|
42
|
+
scheduled_charging_start_time: int | None = None
|
|
43
|
+
charger_type: str | None = None
|
|
44
|
+
ideal_battery_range: float | None = None
|
|
45
|
+
est_battery_range: float | None = None
|
|
46
|
+
usable_battery_level: int | None = None
|
|
47
|
+
charge_energy_added: float | None = None
|
|
48
|
+
charge_miles_added_rated: float | None = None
|
|
49
|
+
charger_power: int | None = None
|
|
50
|
+
time_to_full_charge: float | None = None
|
|
51
|
+
scheduled_charging_mode: str | None = None
|
|
52
|
+
scheduled_departure_time_minutes: int | None = None
|
|
53
|
+
preconditioning_enabled: bool | None = None
|
|
54
|
+
battery_heater_on: bool | None = None
|
|
55
|
+
charge_port_latch: str | None = None
|
|
56
|
+
conn_charge_cable: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ClimateState(BaseModel):
|
|
60
|
+
model_config = _EXTRA_ALLOW
|
|
61
|
+
|
|
62
|
+
inside_temp: float | None = None
|
|
63
|
+
outside_temp: float | None = None
|
|
64
|
+
driver_temp_setting: float | None = None
|
|
65
|
+
passenger_temp_setting: float | None = None
|
|
66
|
+
is_climate_on: bool | None = None
|
|
67
|
+
fan_status: int | None = None
|
|
68
|
+
defrost_mode: int | None = None
|
|
69
|
+
seat_heater_left: int | None = None
|
|
70
|
+
seat_heater_right: int | None = None
|
|
71
|
+
steering_wheel_heater: bool | None = None
|
|
72
|
+
cabin_overheat_protection: str | None = None
|
|
73
|
+
cabin_overheat_protection_actively_cooling: bool | None = None
|
|
74
|
+
is_auto_conditioning_on: bool | None = None
|
|
75
|
+
is_preconditioning: bool | None = None
|
|
76
|
+
seat_heater_rear_left: int | None = None
|
|
77
|
+
seat_heater_rear_center: int | None = None
|
|
78
|
+
seat_heater_rear_right: int | None = None
|
|
79
|
+
bioweapon_defense_mode: bool | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SoftwareUpdateInfo(BaseModel):
|
|
83
|
+
model_config = _EXTRA_ALLOW
|
|
84
|
+
|
|
85
|
+
status: str | None = None
|
|
86
|
+
version: str | None = None
|
|
87
|
+
install_perc: int | None = None
|
|
88
|
+
expected_duration_sec: int | None = None
|
|
89
|
+
scheduled_time_ms: int | None = None
|
|
90
|
+
download_perc: int | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class VehicleState(BaseModel):
|
|
94
|
+
model_config = _EXTRA_ALLOW
|
|
95
|
+
|
|
96
|
+
locked: bool | None = None
|
|
97
|
+
odometer: float | None = None
|
|
98
|
+
sentry_mode: bool | None = None
|
|
99
|
+
car_version: str | None = None
|
|
100
|
+
door_driver_front: int | None = None
|
|
101
|
+
door_driver_rear: int | None = None
|
|
102
|
+
door_passenger_front: int | None = None
|
|
103
|
+
door_passenger_rear: int | None = None
|
|
104
|
+
window_driver_front: int | None = None
|
|
105
|
+
window_driver_rear: int | None = None
|
|
106
|
+
window_passenger_front: int | None = None
|
|
107
|
+
window_passenger_rear: int | None = None
|
|
108
|
+
ft: int | None = None
|
|
109
|
+
rt: int | None = None
|
|
110
|
+
homelink_nearby: bool | None = None
|
|
111
|
+
is_user_present: bool | None = None
|
|
112
|
+
center_display_state: int | None = None
|
|
113
|
+
dashcam_state: str | None = None
|
|
114
|
+
remote_start_enabled: bool | None = None
|
|
115
|
+
tpms_pressure_fl: float | None = None
|
|
116
|
+
tpms_pressure_fr: float | None = None
|
|
117
|
+
tpms_pressure_rl: float | None = None
|
|
118
|
+
tpms_pressure_rr: float | None = None
|
|
119
|
+
software_update: SoftwareUpdateInfo | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class VehicleConfig(BaseModel):
|
|
123
|
+
model_config = _EXTRA_ALLOW
|
|
124
|
+
|
|
125
|
+
car_type: str | None = None
|
|
126
|
+
trim_badging: str | None = None
|
|
127
|
+
exterior_color: str | None = None
|
|
128
|
+
wheel_type: str | None = None
|
|
129
|
+
can_accept_navigation_requests: bool | None = None
|
|
130
|
+
can_actuate_trunks: bool | None = None
|
|
131
|
+
eu_vehicle: bool | None = None
|
|
132
|
+
has_seat_cooling: bool | None = None
|
|
133
|
+
motorized_charge_port: bool | None = None
|
|
134
|
+
plg: bool | None = None
|
|
135
|
+
roof_color: str | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GuiSettings(BaseModel):
|
|
139
|
+
model_config = _EXTRA_ALLOW
|
|
140
|
+
|
|
141
|
+
gui_distance_units: str | None = None
|
|
142
|
+
gui_temperature_units: str | None = None
|
|
143
|
+
gui_charge_rate_units: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class VehicleData(BaseModel):
|
|
147
|
+
model_config = _EXTRA_ALLOW
|
|
148
|
+
|
|
149
|
+
vin: str
|
|
150
|
+
display_name: str | None = None
|
|
151
|
+
state: str = "unknown"
|
|
152
|
+
vehicle_id: int | None = None
|
|
153
|
+
charge_state: ChargeState | None = None
|
|
154
|
+
climate_state: ClimateState | None = None
|
|
155
|
+
drive_state: DriveState | None = None
|
|
156
|
+
vehicle_state: VehicleState | None = None
|
|
157
|
+
vehicle_config: VehicleConfig | None = None
|
|
158
|
+
gui_settings: GuiSettings | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SuperchargerInfo(BaseModel):
|
|
162
|
+
model_config = _EXTRA_ALLOW
|
|
163
|
+
|
|
164
|
+
name: str | None = None
|
|
165
|
+
location: dict[str, float] | None = None
|
|
166
|
+
distance_miles: float | None = None
|
|
167
|
+
total_stalls: int | None = None
|
|
168
|
+
available_stalls: int | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class DestChargerInfo(BaseModel):
|
|
172
|
+
model_config = _EXTRA_ALLOW
|
|
173
|
+
|
|
174
|
+
name: str | None = None
|
|
175
|
+
location: dict[str, float] | None = None
|
|
176
|
+
distance_miles: float | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class NearbyChargingSites(BaseModel):
|
|
180
|
+
model_config = _EXTRA_ALLOW
|
|
181
|
+
|
|
182
|
+
superchargers: list[SuperchargerInfo] = []
|
|
183
|
+
destination_charging: list[DestChargerInfo] = []
|
|
184
|
+
congestion_sync_time_utc_secs: int | None = None
|
|
185
|
+
timestamp: int | None = None
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from tescmd.output.json_output import format_json_error, format_json_response
|
|
9
|
+
from tescmd.output.rich_output import DisplayUnits, RichOutput
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from io import TextIOBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutputFormatter:
|
|
16
|
+
"""Unified output formatter that auto-detects JSON vs Rich output.
|
|
17
|
+
|
|
18
|
+
Selection logic:
|
|
19
|
+
|
|
20
|
+
* If *force_format* is provided, use it unconditionally.
|
|
21
|
+
* Otherwise, if *stream* (default ``sys.stdout``) is a TTY, use ``"rich"``.
|
|
22
|
+
* If the stream is **not** a TTY (piped / redirected), use ``"json"``.
|
|
23
|
+
|
|
24
|
+
When the format is ``"quiet"``, a :class:`rich.console.Console` writing to
|
|
25
|
+
*stderr* is used so that normal stdout stays empty.
|
|
26
|
+
|
|
27
|
+
Error stream routing:
|
|
28
|
+
|
|
29
|
+
* **JSON / piped** — errors go to **stderr** so stdout stays clean for
|
|
30
|
+
machine-parseable data (callers can safely ``| jq``).
|
|
31
|
+
* **Rich / TTY** — errors stay on **stdout** because the user is looking
|
|
32
|
+
at the terminal directly; splitting streams would be worse UX.
|
|
33
|
+
* Interactive prompts (wake confirmation, enrollment approval) always use
|
|
34
|
+
stdout via Rich since they are inherently TTY-only.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
stream: TextIOBase | Any | None = None,
|
|
41
|
+
force_format: str | None = None,
|
|
42
|
+
units: DisplayUnits | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._stream = stream or sys.stdout
|
|
45
|
+
if force_format is not None:
|
|
46
|
+
self._format = force_format
|
|
47
|
+
elif hasattr(self._stream, "isatty") and self._stream.isatty():
|
|
48
|
+
self._format = "rich"
|
|
49
|
+
else:
|
|
50
|
+
self._format = "json"
|
|
51
|
+
|
|
52
|
+
# Build the Rich console — quiet mode writes to stderr.
|
|
53
|
+
if self._format == "quiet":
|
|
54
|
+
self._console = Console(stderr=True)
|
|
55
|
+
else:
|
|
56
|
+
self._console = Console()
|
|
57
|
+
|
|
58
|
+
# Separate stderr console for error output so stdout stays clean
|
|
59
|
+
# for machine-parseable data (JSON, piped workflows).
|
|
60
|
+
self._error_console = Console(stderr=True)
|
|
61
|
+
|
|
62
|
+
self._rich = RichOutput(self._console, units=units)
|
|
63
|
+
self._cache_meta: dict[str, Any] | None = None
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Public helpers
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def format(self) -> str:
|
|
71
|
+
"""Return the active output format (``"rich"``, ``"json"``, or ``"quiet"``)."""
|
|
72
|
+
return self._format
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def console(self) -> Console:
|
|
76
|
+
"""Return the underlying :class:`Console` instance."""
|
|
77
|
+
return self._console
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def rich(self) -> RichOutput:
|
|
81
|
+
"""Return the underlying :class:`RichOutput` instance."""
|
|
82
|
+
return self._rich
|
|
83
|
+
|
|
84
|
+
def set_cache_meta(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
hit: bool,
|
|
88
|
+
age_seconds: int,
|
|
89
|
+
ttl_seconds: int,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Store cache metadata to be included in the next JSON output."""
|
|
92
|
+
self._cache_meta = {
|
|
93
|
+
"hit": hit,
|
|
94
|
+
"age_seconds": age_seconds,
|
|
95
|
+
"ttl_seconds": ttl_seconds,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def output(self, data: Any, *, command: str) -> None:
|
|
99
|
+
"""Emit *data* using the current format.
|
|
100
|
+
|
|
101
|
+
* **json** — prints :func:`format_json_response` to stdout.
|
|
102
|
+
* **rich** / **quiet** — delegates to :attr:`rich` methods when the
|
|
103
|
+
data type is recognised, otherwise falls back to
|
|
104
|
+
:meth:`RichOutput.info` with a ``str()`` representation.
|
|
105
|
+
"""
|
|
106
|
+
if self._format == "json":
|
|
107
|
+
print(format_json_response(data=data, command=command, cache_meta=self._cache_meta))
|
|
108
|
+
self._cache_meta = None
|
|
109
|
+
else:
|
|
110
|
+
# Rich / quiet fallback — callers normally use self.rich directly
|
|
111
|
+
# for typed output; this is a catch-all.
|
|
112
|
+
self._rich.info(str(data))
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def error_console(self) -> Console:
|
|
116
|
+
"""Return the stderr :class:`Console` for error output."""
|
|
117
|
+
return self._error_console
|
|
118
|
+
|
|
119
|
+
def output_error(self, *, code: str, message: str, command: str) -> None:
|
|
120
|
+
"""Emit an error using the current format.
|
|
121
|
+
|
|
122
|
+
* **json** — prints :func:`format_json_error` to stderr.
|
|
123
|
+
* **rich** / **quiet** — prints via :meth:`RichOutput.error` (stdout,
|
|
124
|
+
since TTY users see the terminal directly).
|
|
125
|
+
"""
|
|
126
|
+
if self._format == "json":
|
|
127
|
+
print(
|
|
128
|
+
format_json_error(code=code, message=message, command=command),
|
|
129
|
+
file=sys.stderr,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
self._rich.error(message)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _serialize(obj: Any) -> Any:
|
|
11
|
+
"""Convert *obj* to a JSON-friendly structure.
|
|
12
|
+
|
|
13
|
+
* :class:`pydantic.BaseModel` instances are dumped via
|
|
14
|
+
:meth:`~pydantic.BaseModel.model_dump` with *exclude_none=True*.
|
|
15
|
+
* Lists are recursed element-wise.
|
|
16
|
+
* Dicts are recursed value-wise (nested Pydantic models are serialized).
|
|
17
|
+
* Everything else is returned as-is (``json.dumps`` handles the rest via
|
|
18
|
+
*default=str*).
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(obj, BaseModel):
|
|
21
|
+
return obj.model_dump(exclude_none=True)
|
|
22
|
+
if isinstance(obj, dict):
|
|
23
|
+
return {k: _serialize(v) for k, v in obj.items()}
|
|
24
|
+
if isinstance(obj, list):
|
|
25
|
+
return [_serialize(item) for item in obj]
|
|
26
|
+
return obj
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_json_response(
|
|
30
|
+
*,
|
|
31
|
+
data: Any,
|
|
32
|
+
command: str,
|
|
33
|
+
cache_meta: dict[str, Any] | None = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Return a JSON envelope for a successful response.
|
|
36
|
+
|
|
37
|
+
The envelope has the shape::
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
"ok": true,
|
|
41
|
+
"command": "<command>",
|
|
42
|
+
"data": <serialised payload>,
|
|
43
|
+
"timestamp": "<ISO-8601 UTC>",
|
|
44
|
+
"_cache": {"hit": true, "age_seconds": N, "ttl_seconds": M} // optional
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
envelope: dict[str, Any] = {
|
|
48
|
+
"ok": True,
|
|
49
|
+
"command": command,
|
|
50
|
+
"data": _serialize(data),
|
|
51
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
52
|
+
}
|
|
53
|
+
if cache_meta is not None:
|
|
54
|
+
envelope["_cache"] = cache_meta
|
|
55
|
+
return json.dumps(envelope, indent=2, default=str)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_json_error(
|
|
59
|
+
*,
|
|
60
|
+
code: str,
|
|
61
|
+
message: str,
|
|
62
|
+
command: str,
|
|
63
|
+
**extra: Any,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Return a JSON envelope for an error response.
|
|
66
|
+
|
|
67
|
+
The envelope has the shape::
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
"ok": false,
|
|
71
|
+
"command": "<command>",
|
|
72
|
+
"error": {"code": "...", "message": "...", ...extra},
|
|
73
|
+
"timestamp": "<ISO-8601 UTC>"
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
error_body: dict[str, Any] = {"code": code, "message": message, **extra}
|
|
77
|
+
envelope: dict[str, Any] = {
|
|
78
|
+
"ok": False,
|
|
79
|
+
"command": command,
|
|
80
|
+
"error": error_body,
|
|
81
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
82
|
+
}
|
|
83
|
+
return json.dumps(envelope, indent=2, default=str)
|