py-livewire 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
py_livewire/const.py ADDED
@@ -0,0 +1,36 @@
1
+ """Constants of the LiveWire mobile API, as used by the LiveWire EV app 1.9.0."""
2
+
3
+ API_URL = "https://mobileapi.livewire.com/api"
4
+ GIGYA_URL = "https://accounts.us1.gigya.com"
5
+
6
+ # The app logs in with the global Gigya key and data center, regardless of the
7
+ # user's region
8
+ GIGYA_API_KEY = "4_6aX8cf8RFVt6F3JQQZaF3A"
9
+ DATA_CENTER = "us1"
10
+
11
+ BRAND = "LiveWire"
12
+ APP_VERSION = "1.9.0"
13
+
14
+ # What the app reports about the phone: "<manufacturer> <model>" and the
15
+ # Android SDK level
16
+ DEVICE_MODEL = "Google Pixel 8"
17
+ OS_VERSION = "34"
18
+ LANGUAGE = "en-US"
19
+
20
+ KM_PER_MILE = 1.609344
21
+
22
+ # Values of error.code in the response envelope
23
+ CODE_OK = "0"
24
+ CODE_PARTIAL = "-300" # the app still shows the charging data that comes with it
25
+ CODE_NOT_PAIRED = "3000"
26
+ CODE_MAINTENANCE_OK = "5101"
27
+
28
+ # Values of the top-level "code" when verifying a pairing code
29
+ CODE_ATTEMPTS_EXCEEDED = 4003
30
+ CODE_WRONG_CODE = 4004
31
+
32
+ # Top-level "code" of the tire pressure response when the bike reported none
33
+ CODE_NO_DATA = 3000
34
+
35
+ # Charging schedules use English short day names
36
+ DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
@@ -0,0 +1,33 @@
1
+ class LivewireError(Exception):
2
+ """Base class of all errors raised by this library."""
3
+
4
+
5
+ class AuthenticationError(LivewireError):
6
+ """Logging in failed."""
7
+
8
+
9
+ class ApiError(LivewireError):
10
+ """The backend answered with an error, as HTTP status or as error envelope."""
11
+
12
+ def __init__(
13
+ self, message: str, *, code: str | None = None, status: int | None = None
14
+ ):
15
+ super().__init__(message)
16
+ self.code = code
17
+ self.status = status
18
+
19
+
20
+ class NotPairedError(ApiError):
21
+ """The bike did not answer for this device UUID (error code 3000).
22
+
23
+ The backend reports this when the device UUID is not paired with the bike,
24
+ and also when it cannot reach the bike at all.
25
+ """
26
+
27
+
28
+ class PairingError(LivewireError):
29
+ """Requesting or verifying a pairing code failed."""
30
+
31
+ def __init__(self, message: str, *, code: int | None = None):
32
+ super().__init__(message)
33
+ self.code = code
py_livewire/models.py ADDED
@@ -0,0 +1,245 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime, time, timedelta
3
+ from typing import Any
4
+
5
+ from .const import KM_PER_MILE
6
+ from .utils import to_bool, to_datetime, to_float, to_int, to_timedelta
7
+
8
+
9
+ def _str(value: Any) -> str | None:
10
+ return None if value is None else str(value)
11
+
12
+
13
+ def _miles_to_km(value: float | None) -> float | None:
14
+ return None if value is None else value * KM_PER_MILE
15
+
16
+
17
+ @dataclass
18
+ class Bike:
19
+ id: str
20
+ vin: str | None
21
+ name: str | None
22
+ make: str | None
23
+ model: str | None
24
+ model_code: str | None
25
+ model_number: str | None
26
+ year: int | None
27
+ color: str | None
28
+ created: datetime | None
29
+ primary: bool
30
+ # Whether the client's device UUID is paired with the bike, None if the
31
+ # response did not tell
32
+ paired: bool | None
33
+ raw: dict[str, Any] = field(repr=False)
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: dict[str, Any]) -> "Bike":
37
+ return cls(
38
+ id=str(data["id"]),
39
+ vin=data.get("vin"),
40
+ name=data.get("name"),
41
+ make=data.get("make"),
42
+ model=data.get("model"),
43
+ model_code=data.get("modelCode"),
44
+ model_number=data.get("modelNumber"),
45
+ year=to_int(data.get("year")),
46
+ color=data.get("color"),
47
+ created=to_datetime(data.get("createdTime")),
48
+ primary=bool(to_bool(data.get("primaryBike"))),
49
+ paired=to_bool(data.get("pairingStatus")),
50
+ raw=data,
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class ChargingStatus:
56
+ battery_level: float | None # percent
57
+ charging: bool
58
+ plugged_in: bool
59
+ charge_limit: int | None # percent
60
+ time_to_limit: timedelta | None
61
+ range_miles: float | None
62
+ odometer_miles: float | None
63
+ # How long ago the bike reported this data
64
+ data_age: timedelta | None
65
+ # Enhanced vehicle tracking: SAFE, STOLEN or PENDING
66
+ tracking_status: str | None
67
+ raw: dict[str, Any] = field(repr=False)
68
+
69
+ @property
70
+ def range_km(self) -> float | None:
71
+ return _miles_to_km(self.range_miles)
72
+
73
+ @property
74
+ def odometer_km(self) -> float | None:
75
+ return _miles_to_km(self.odometer_miles)
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict[str, Any]) -> "ChargingStatus":
79
+ status = data.get("bikeChargingData") or {}
80
+ time_to_limit = to_float(status.get("timeToMaxLimit"))
81
+
82
+ return cls(
83
+ battery_level=to_float(status.get("batteryPercentage")),
84
+ charging=bool(to_bool(status.get("chargingStatus"))),
85
+ plugged_in=bool(to_bool(status.get("pluggedIn"))),
86
+ charge_limit=to_int(status.get("maxLimit")) or None,
87
+ time_to_limit=None
88
+ if time_to_limit is None
89
+ else timedelta(minutes=time_to_limit),
90
+ range_miles=to_float(status.get("range")),
91
+ odometer_miles=to_float(status.get("odometer")),
92
+ data_age=to_timedelta(
93
+ status.get("durationElapsed"), status.get("durationUnit")
94
+ ),
95
+ tracking_status=data.get("trackingStatus"),
96
+ raw=data,
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class Location:
102
+ latitude: float | None
103
+ longitude: float | None
104
+ altitude: float | None # meters
105
+ speed_kmh: float | None
106
+ gps_time: datetime | None
107
+ reported_at: datetime | None
108
+ fix_type: int | None
109
+ raw: dict[str, Any] = field(repr=False)
110
+
111
+ @classmethod
112
+ def from_dict(cls, data: dict[str, Any]) -> "Location":
113
+ return cls(
114
+ latitude=to_float(data.get("latitude")),
115
+ longitude=to_float(data.get("longitude")),
116
+ altitude=to_float(data.get("altitude")),
117
+ speed_kmh=to_float(data.get("speed_km")),
118
+ gps_time=to_datetime(data.get("gps_time")),
119
+ reported_at=to_datetime(data.get("tcu_timestamp")),
120
+ fix_type=to_int(data.get("fix_type")),
121
+ raw=data,
122
+ )
123
+
124
+
125
+ @dataclass
126
+ class TirePressure:
127
+ front: float | None
128
+ rear: float | None
129
+ front_recommended: float | None
130
+ rear_recommended: float | None
131
+ unit: str | None
132
+ front_low: bool
133
+ rear_low: bool
134
+ raw: dict[str, Any] = field(repr=False)
135
+
136
+ @classmethod
137
+ def from_dict(cls, data: dict[str, Any]) -> "TirePressure":
138
+ front = data.get("TirePressureFront") or {}
139
+ rear = data.get("TirePressureRear") or {}
140
+
141
+ def value(key: str) -> float | None:
142
+ return to_float((data.get(key) or {}).get("value"))
143
+
144
+ return cls(
145
+ front=to_float(front.get("value")),
146
+ rear=to_float(rear.get("value")),
147
+ # sic, the backend misspells these
148
+ front_recommended=value("RecomendedTirePressureFront"),
149
+ rear_recommended=value("RecomendedTirePressureRear"),
150
+ unit=front.get("UnitOfMeasure") or rear.get("UnitOfMeasure"),
151
+ front_low=bool(to_int(data.get("FrontTireLow"))),
152
+ rear_low=bool(to_int(data.get("RearTireLow"))),
153
+ raw=data,
154
+ )
155
+
156
+
157
+ def _to_time(value: Any) -> time | None:
158
+ try:
159
+ return time.fromisoformat(value)
160
+ except (TypeError, ValueError):
161
+ return None
162
+
163
+
164
+ @dataclass
165
+ class ChargingSchedule:
166
+ id: str
167
+ # ACTIVE, INACTIVE or PENDING while the bike has not confirmed a change yet
168
+ status: str | None
169
+ days: list[str]
170
+ # Local time of the offset below, the window may cross midnight
171
+ start: time | None
172
+ end: time | None
173
+ timezone_offset_minutes: int | None
174
+ raw: dict[str, Any] = field(repr=False)
175
+
176
+ @property
177
+ def enabled(self) -> bool:
178
+ return (self.status or "").upper() == "ACTIVE"
179
+
180
+ @classmethod
181
+ def from_dict(cls, data: dict[str, Any]) -> "ChargingSchedule":
182
+ window = data.get("time") or {}
183
+
184
+ return cls(
185
+ id=str(data.get("scheduleId")),
186
+ status=data.get("status"),
187
+ days=list(data.get("days") or []),
188
+ start=_to_time(window.get("start")),
189
+ end=_to_time(window.get("end")),
190
+ timezone_offset_minutes=to_int(data.get("timezoneOffsetMinutes")),
191
+ raw=data,
192
+ )
193
+
194
+
195
+ @dataclass
196
+ class PairedUser:
197
+ user_id: str | None
198
+ device_uuid: str | None
199
+ device_model: str | None
200
+ device_type: str | None
201
+ email: str | None
202
+ first_name: str | None
203
+ last_name: str | None
204
+ paired_since: datetime | None
205
+ ready_to_unpair: bool
206
+ raw: dict[str, Any] = field(repr=False)
207
+
208
+ @classmethod
209
+ def from_dict(cls, data: dict[str, Any]) -> "PairedUser":
210
+ return cls(
211
+ user_id=_str(data.get("userId")),
212
+ device_uuid=data.get("deviceUUID"),
213
+ device_model=data.get("deviceModel"),
214
+ device_type=data.get("deviceType"),
215
+ email=data.get("userEmail"),
216
+ first_name=data.get("userFirstName"),
217
+ last_name=data.get("userLastName"),
218
+ paired_since=to_datetime(data.get("createdTime")),
219
+ ready_to_unpair=bool(to_bool(data.get("readyToUnpair"))),
220
+ raw=data,
221
+ )
222
+
223
+
224
+ @dataclass
225
+ class SoftwareVersion:
226
+ bike_id: str
227
+ vin: str | None
228
+ current: str | None
229
+ latest: str | None
230
+ # MATCH, MISMATCH or NEW_FEATURE_AVAILABLE
231
+ status: str | None
232
+ feature_ids: list[str]
233
+ raw: dict[str, Any] = field(repr=False)
234
+
235
+ @classmethod
236
+ def from_dict(cls, data: dict[str, Any]) -> "SoftwareVersion":
237
+ return cls(
238
+ bike_id=str(data.get("bikeId")),
239
+ vin=data.get("vin"),
240
+ current=data.get("currentVsbVersion"),
241
+ latest=data.get("maxAvailableVsbVersion"),
242
+ status=data.get("vsbMatchStatus"),
243
+ feature_ids=list(data.get("featureIds") or []),
244
+ raw=data,
245
+ )
py_livewire/py.typed ADDED
File without changes
py_livewire/utils.py ADDED
@@ -0,0 +1,87 @@
1
+ import hashlib
2
+ import math
3
+ import re
4
+ import uuid
5
+ from datetime import datetime, timedelta
6
+ from typing import Any
7
+
8
+
9
+ def to_float(value: Any) -> float | None:
10
+ """Convert a number arriving as JSON number or string, None if unparseable."""
11
+ if value is None or isinstance(value, bool):
12
+ return None
13
+ try:
14
+ res = float(value)
15
+ except (TypeError, ValueError):
16
+ return None
17
+ return res if math.isfinite(res) else None
18
+
19
+
20
+ def to_int(value: Any) -> int | None:
21
+ res = to_float(value)
22
+ return None if res is None else int(res)
23
+
24
+
25
+ def to_bool(value: Any) -> bool | None:
26
+ if isinstance(value, bool) or value is None:
27
+ return value
28
+ if isinstance(value, str):
29
+ return {"true": True, "false": False}.get(value.strip().lower())
30
+ if isinstance(value, (int, float)):
31
+ return bool(value)
32
+ return None
33
+
34
+
35
+ def to_datetime(value: Any) -> datetime | None:
36
+ if not isinstance(value, str) or not value:
37
+ return None
38
+ try:
39
+ return datetime.fromisoformat(value)
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ _DURATION_UNITS = {
45
+ "second": "seconds",
46
+ "minute": "minutes",
47
+ "hour": "hours",
48
+ "day": "days",
49
+ "week": "weeks",
50
+ }
51
+
52
+
53
+ def to_timedelta(value: Any, unit: str | None) -> timedelta | None:
54
+ """Convert a value like ("2", "days") as used by the charging status."""
55
+ amount = to_float(value)
56
+ if amount is None or not unit:
57
+ return None
58
+ name = _DURATION_UNITS.get(unit.strip().lower().rstrip("s"))
59
+ return timedelta(**{name: amount}) if name else None
60
+
61
+
62
+ def uuid_from_android_id(android_id: str) -> str:
63
+ """Derive the device UUID the Android app uses from an ANDROID_ID.
64
+
65
+ The app computes UUID.nameUUIDFromBytes(ANDROID_ID), i.e. an MD5 based version 3
66
+ UUID without namespace. Since Android 8 the ANDROID_ID differs per app signing key,
67
+ so the value of "adb shell settings get secure android_id" is not the one the app
68
+ sees; with root it is in /data/system/users/0/settings_ssaid.xml. Easier is reading
69
+ the UUID the app logs, see device_uuids_in_log().
70
+ """
71
+ digest = hashlib.md5(android_id.encode()).digest()
72
+ return str(uuid.UUID(bytes=digest, version=3))
73
+
74
+
75
+ # The app logs its device UUID with this tag whenever it uses it, e.g. on launch
76
+ LOG_TAG = "## UUID ##"
77
+
78
+ _LOGGED_UUID = re.compile(
79
+ re.escape(LOG_TAG)
80
+ + r".*?\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b",
81
+ re.IGNORECASE,
82
+ )
83
+
84
+
85
+ def device_uuids_in_log(log: str) -> list[str]:
86
+ """Device UUIDs the app logged in logcat output, in order, without repeats."""
87
+ return list(dict.fromkeys(_LOGGED_UUID.findall(log)))
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-livewire
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the LiveWire electric motorcycle cloud API
5
+ Author-email: Igor Novgorodov <igor@novg.net>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/hass-livewire/py-livewire
8
+ Project-URL: Issues, https://github.com/hass-livewire/py-livewire/issues
9
+ Keywords: livewire,harley-davidson,motorcycle,electric,ev,api,cloud
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: English
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: aiohttp>=3.10
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=8; extra == "test"
26
+ Provides-Extra: dev
27
+ Requires-Dist: py-livewire[test]; extra == "dev"
28
+ Requires-Dist: ruff>=0.6; extra == "dev"
29
+ Requires-Dist: mypy>=1.10; extra == "dev"
30
+ Requires-Dist: build; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # py-livewire
34
+
35
+ Async Python client for the LiveWire motorcycle cloud: the same API the LiveWire EV app
36
+ (Android 1.9.0) uses, covering bikes, pairing, charging status and control, position, tire
37
+ pressure, analytics, charging schedules, maintenance, roadside assistance, rides and dealers.
38
+
39
+ > This project is not affiliated with LiveWire or Harley-Davidson. It uses an undocumented
40
+ > API that may change or stop working at any time.
41
+
42
+ ## The device UUID
43
+
44
+ Every session with the backend is opened for a *device UUID*. The app generates it on the
45
+ phone (`UUID.nameUUIDFromBytes(ANDROID_ID)`); it is no secret, just an identifier. The backend
46
+ only answers the **charging status** for device UUIDs that are **paired** with the bike, and
47
+ otherwise returns error code `3000` ("Command manager api failed"), raised as `NotPairedError`.
48
+ In testing, other data like the position, tire pressure and analytics was also served to
49
+ unpaired UUIDs.
50
+
51
+ There are two ways to get a paired UUID:
52
+
53
+ - **Pair a new one** (recommended). Pairing happens through the cloud, no Bluetooth needed:
54
+ the backend makes the bike show a 6-digit code on its display, which is sent back. You have
55
+ to be at the bike once, with the ignition on, not in propulsion mode, not charging and with
56
+ cellular reception. `livewire pair` does this and prints the UUID to keep.
57
+ - **Reuse an already paired UUID**, e.g. the one of your phone, see below.
58
+
59
+ A paired UUID keeps working across logins. A new UUID needs pairing again.
60
+
61
+ ### Finding your phone's UUID
62
+
63
+ The app doesn't show the UUID anywhere, but it writes it to the Android system log (logcat)
64
+ with the tag `## UUID ##` whenever it uses it, including on launch. The log can be read without
65
+ root:
66
+
67
+ - **From a computer:** enable USB (or wireless) debugging on the phone, connect it, and run
68
+ `livewire phone-uuid`, then open the LiveWire app. This needs `adb` from the
69
+ Android platform tools. By hand, `adb logcat -v tag | grep "## UUID ##"` shows the same.
70
+ - **On the phone alone** (Android 11+): enable wireless debugging and run `adb logcat` from a
71
+ local adb client such as LADB or `adb` in Termux, or use a log viewer app after granting it
72
+ access once with `adb shell pm grant <app package> android.permission.READ_LOGS`.
73
+
74
+ With root, the UUID is in `/data/data/com.livewire.arrow/shared_prefs/locale.xml`, key
75
+ `device_uuid`. The UUID survives reinstalling the app, but changes with a factory reset.
76
+
77
+ Other routes don't work. Another app can't compute the UUID, because the device ID it is
78
+ derived from differs per app since Android 8. The app is excluded from backups, and it trusts
79
+ no user-installed certificates, so a TLS proxy needs root. Also, the backend's list of paired
80
+ devices answers HTTP 401.
81
+
82
+ Some phone makers' builds drop the debug log messages this relies on.
83
+
84
+ ## Installation
85
+
86
+ Python 3.11 or newer.
87
+
88
+ ```
89
+ pip install py-livewire
90
+ ```
91
+
92
+ This also installs the `livewire` command line tool (or run `python -m py_livewire`).
93
+
94
+ ## Command line
95
+
96
+ Credentials come from `LW_EMAIL` and `LW_PASSWORD` (prompted for otherwise), the device UUID
97
+ from `LW_DEVICE_UUID` or `--device-uuid`.
98
+
99
+ ```
100
+ livewire pair # once, at the bike: pairs a new UUID and prints it
101
+ export LW_DEVICE_UUID=<the printed UUID>
102
+ livewire status # battery, charging, range, odometer, position
103
+ livewire bikes # bikes of the account and their pairing state
104
+ ```
105
+
106
+ With several bikes on the account, choose one with `--bike <id or VIN>`.
107
+
108
+ ## Library
109
+
110
+ ```python
111
+ import asyncio
112
+
113
+ from py_livewire import Client
114
+
115
+
116
+ async def main():
117
+ async with Client("user@example.org", "password", device_uuid="…") as client:
118
+ bike = (await client.get_bikes())[0]
119
+
120
+ status = await client.get_charging_status(bike.id)
121
+ print(status.battery_level, status.range_km, status.charging)
122
+
123
+ location = await client.get_location(bike.id)
124
+ print(location.latitude, location.longitude)
125
+
126
+
127
+ asyncio.run(main())
128
+ ```
129
+
130
+ Pairing from code: `await client.pair(bike.id, get_code)`, where `get_code` is a plain or
131
+ coroutine function returning the code from the bike's display. Afterwards `client.device_uuid`
132
+ is the UUID to keep.
133
+
134
+ `Client` logs in on the first request, and logs in again once if the backend rejects the
135
+ session token. Pass `session=` to use your own `aiohttp.ClientSession`.
136
+
137
+ ### Methods
138
+
139
+ | Area | Methods |
140
+ |---|---|
141
+ | Bikes | `get_bikes`, `get_vin_info`, `add_bike`, `update_bike`, `delete_bike` |
142
+ | Pairing | `is_paired`, `pair`, `request_pairing_code`, `verify_pairing_code`, `get_paired_users`, `request_unpairing_code`, `verify_unpairing_code` |
143
+ | Vehicle data | `get_charging_status`, `get_location`, `get_tire_pressure`, `get_software_versions`, `get_software_release_notes`, `get_charging_analytics`, `get_ride_analytics` |
144
+ | Charging | `set_charge_limit`, `start_charging`, `get_charging_schedules`, `add_charging_schedule`, `update_charging_schedule`, `delete_charging_schedule` |
145
+ | Tracking | `set_enhanced_tracking`, `set_tracking_consent` |
146
+ | Maintenance | `get_maintenance_records`, `get_maintenance_schedule`, `add/update/delete_maintenance_record`, `get/add/update/delete_roadside_assistance` |
147
+ | Rides | `get_rides`, `get_my_rides`, `get_ride`, `get_shared_ride`, `save_ride`, `update_ride`, `rename_ride`, `delete_ride`, `convert_recorded_ride`, `get_bookmarked_rides`, `bookmark_ride`, `remove_bookmark` |
148
+ | Dealers | `get_dealers` |
149
+ | Account and app | `get_notification_settings`, `update_notification_setting`, `set_language`, `get_external_urls`, `get_app_config`, `get_minimum_app_version`, `get_release_notes`, `get_countries`, `delete_account` |
150
+
151
+ Vehicle data comes as dataclasses (`Bike`, `ChargingStatus`, `Location`, `TirePressure`,
152
+ `ChargingSchedule`, `PairedUser`, `SoftwareVersion`), each with the full response in `raw`.
153
+ Range and odometer arrive in miles; `ChargingStatus` also offers them in km. The rest returns
154
+ the parsed JSON.
155
+
156
+ Errors are `AuthenticationError`, `ApiError` (with `code` and `status`), its subclass
157
+ `NotPairedError`, and `PairingError`, all derived from `LivewireError`.
158
+
159
+ ### Backend quirks
160
+
161
+ - Errors mostly arrive with HTTP 200 as `{"error": {"code": …, "description": …}}`.
162
+ - Numbers like the battery level arrive as JSON numbers or strings; both are handled.
163
+ - `get_paired_users` was answered with HTTP 401 in testing, even for a paired UUID.
164
+ - There is no working endpoint to cancel a pairing request; the app's is broken. An unused code
165
+ expires.
166
+ - The app's endpoints for ride tags and the home screen battery status no longer exist on the
167
+ backend and are left out.
168
+
169
+ ## Development
170
+
171
+ ```sh
172
+ python -m venv .venv && . .venv/bin/activate
173
+ pip install -e ".[dev]"
174
+ ruff check . && ruff format --check . && mypy && pytest
175
+ ```
176
+
177
+ The tests run against a local fake backend and need no account.
178
+
179
+ To release, bump `__version__` in `py_livewire/__init__.py` and push a `v*` tag (or
180
+ publish a GitHub release); the PyPI Release workflow tests, builds and uploads it.
@@ -0,0 +1,15 @@
1
+ py_livewire/__init__.py,sha256=_2MM92LJ53Es1MTifh5p_bN_-8MXt_DmLzHZH0TV934,731
2
+ py_livewire/__main__.py,sha256=PCX2FOCmvd5pI59rpBnS8s0RYcHHIx98eMfBGAVldyo,8479
3
+ py_livewire/auth.py,sha256=bAZDNeqnic_dYLNuENx-NKdVUPj2potaLnTyt6Z03mg,3897
4
+ py_livewire/client.py,sha256=GwDYJJkNX7p6aqdSgj2vlBu8BN4y4OcBsuwMsrWk8_g,34416
5
+ py_livewire/const.py,sha256=tBFoHZvgkCrYjPY8mfNz6unSDqaFZOI_EFkSf-AnN5Y,1084
6
+ py_livewire/exceptions.py,sha256=wx7usyjPZH5GPpFVZEwGzBmfnpzK0aLlBcud7z8uxEs,940
7
+ py_livewire/models.py,sha256=7ylhhcHAWlwYtKMdVOBJujO0YGbXOEtP2_aqyAJ1fp4,7683
8
+ py_livewire/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ py_livewire/utils.py,sha256=Z2_5IaR5j0hEeTgoSeOMKlErlWk3Hb7MmkEAS2kw2BQ,2649
10
+ py_livewire-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ py_livewire-0.1.0.dist-info/METADATA,sha256=4lyiKtvlxhTlvGm2QEdA8tZB9AW8s6AO-xfH6Foea1M,8180
12
+ py_livewire-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ py_livewire-0.1.0.dist-info/entry_points.txt,sha256=F-PA9oTW3Mb4cx-dNYs7kJ7gFAJMuGei6ZNAnqwGtVY,55
14
+ py_livewire-0.1.0.dist-info/top_level.txt,sha256=71YccZC1kY0V-PvjIBZaCnGqxyWb35AlmVVIo1h9uTI,12
15
+ py_livewire-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ livewire = py_livewire.__main__:main