pykoplenti 1.2.2__py3-none-any.whl → 1.4.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.
Potentially problematic release.
This version of pykoplenti might be problematic. Click here for more details.
- pykoplenti/api.py +55 -67
- pykoplenti/cli.py +200 -60
- pykoplenti/model.py +28 -6
- {pykoplenti-1.2.2.dist-info → pykoplenti-1.4.0.dist-info}/METADATA +27 -16
- pykoplenti-1.4.0.dist-info/RECORD +12 -0
- {pykoplenti-1.2.2.dist-info → pykoplenti-1.4.0.dist-info}/WHEEL +1 -1
- pykoplenti-1.2.2.dist-info/RECORD +0 -12
- {pykoplenti-1.2.2.dist-info → pykoplenti-1.4.0.dist-info}/entry_points.txt +0 -0
- {pykoplenti-1.2.2.dist-info → pykoplenti-1.4.0.dist-info/licenses}/LICENSE +0 -0
- {pykoplenti-1.2.2.dist-info → pykoplenti-1.4.0.dist-info}/top_level.txt +0 -0
pykoplenti/api.py
CHANGED
|
@@ -13,17 +13,16 @@ import warnings
|
|
|
13
13
|
|
|
14
14
|
from Crypto.Cipher import AES
|
|
15
15
|
from aiohttp import ClientResponse, ClientSession, ClientTimeout
|
|
16
|
-
from pydantic import parse_obj_as
|
|
17
16
|
from yarl import URL
|
|
18
17
|
|
|
19
18
|
from .model import (
|
|
20
19
|
EventData,
|
|
21
20
|
MeData,
|
|
22
21
|
ModuleData,
|
|
23
|
-
ProcessData,
|
|
24
22
|
ProcessDataCollection,
|
|
25
23
|
SettingsData,
|
|
26
24
|
VersionData,
|
|
25
|
+
process_data_list,
|
|
27
26
|
)
|
|
28
27
|
|
|
29
28
|
_logger: Final = logging.getLogger(__name__)
|
|
@@ -86,6 +85,20 @@ class ModuleNotFoundException(ApiException):
|
|
|
86
85
|
self.error = error
|
|
87
86
|
|
|
88
87
|
|
|
88
|
+
def _relogin(fn):
|
|
89
|
+
"""Decorator for automatic re-login if session was expired."""
|
|
90
|
+
|
|
91
|
+
@functools.wraps(fn)
|
|
92
|
+
async def _wrapper(self: "ApiClient", *args, **kwargs):
|
|
93
|
+
with contextlib.suppress(AuthenticationException, NotAuthorizedException):
|
|
94
|
+
return await fn(self, *args, **kwargs)
|
|
95
|
+
_logger.debug("Request failed - try to re-login")
|
|
96
|
+
await self._login()
|
|
97
|
+
return await fn(self, *args, **kwargs)
|
|
98
|
+
|
|
99
|
+
return _wrapper
|
|
100
|
+
|
|
101
|
+
|
|
89
102
|
class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
90
103
|
"""Client for the REST-API of Kostal Plenticore inverters.
|
|
91
104
|
|
|
@@ -265,7 +278,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
265
278
|
client_signature = hmac.new(
|
|
266
279
|
stored_key, auth_msg.encode("utf-8"), hashlib.sha256
|
|
267
280
|
).digest()
|
|
268
|
-
client_proof = bytes(
|
|
281
|
+
client_proof = bytes(a ^ b for a, b in zip(client_key, client_signature))
|
|
269
282
|
|
|
270
283
|
server_key = hmac.new(
|
|
271
284
|
salted_passwd, "Server Key".encode("utf-8"), hashlib.sha256
|
|
@@ -343,47 +356,32 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
343
356
|
"""Check if the given response contains an error and throws
|
|
344
357
|
the appropriate exception."""
|
|
345
358
|
|
|
346
|
-
if resp.status
|
|
347
|
-
|
|
348
|
-
response = await resp.json()
|
|
349
|
-
error = response["message"]
|
|
350
|
-
except Exception:
|
|
351
|
-
error = None
|
|
352
|
-
|
|
353
|
-
if resp.status == 400:
|
|
354
|
-
raise AuthenticationException(resp.status, error)
|
|
359
|
+
if resp.status == 200:
|
|
360
|
+
return
|
|
355
361
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
362
|
+
try:
|
|
363
|
+
response = await resp.json()
|
|
364
|
+
error = response["message"]
|
|
365
|
+
except Exception:
|
|
366
|
+
error = None
|
|
361
367
|
|
|
362
|
-
|
|
363
|
-
|
|
368
|
+
if resp.status == 400:
|
|
369
|
+
raise AuthenticationException(resp.status, error)
|
|
364
370
|
|
|
365
|
-
|
|
366
|
-
|
|
371
|
+
if resp.status == 401:
|
|
372
|
+
raise NotAuthorizedException(resp.status, error)
|
|
367
373
|
|
|
368
|
-
|
|
369
|
-
raise
|
|
374
|
+
if resp.status == 403:
|
|
375
|
+
raise UserLockedException(resp.status, error)
|
|
370
376
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
"""Decorator for automatic re-login if session was expired."""
|
|
377
|
+
if resp.status == 404:
|
|
378
|
+
raise ModuleNotFoundException(resp.status, error)
|
|
374
379
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
try:
|
|
378
|
-
return await fn(self, *args, **kwargs)
|
|
379
|
-
except (AuthenticationException, NotAuthorizedException):
|
|
380
|
-
pass
|
|
380
|
+
if resp.status == 503:
|
|
381
|
+
raise InternalCommunicationException(resp.status, error)
|
|
381
382
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return await fn(self, *args, **kwargs)
|
|
385
|
-
|
|
386
|
-
return _wrapper
|
|
383
|
+
# we got an undocumented status code
|
|
384
|
+
raise ApiException(f"Unknown API response [{resp.status}] - {error}")
|
|
387
385
|
|
|
388
386
|
async def logout(self):
|
|
389
387
|
"""Logs the current user out."""
|
|
@@ -422,7 +420,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
422
420
|
if lang is None:
|
|
423
421
|
lang = locale.getlocale()[0]
|
|
424
422
|
|
|
425
|
-
language = lang[
|
|
423
|
+
language = lang[:2].lower()
|
|
426
424
|
variant = lang[3:5].lower()
|
|
427
425
|
if language not in ApiClient.SUPPORTED_LANGUAGES.keys():
|
|
428
426
|
# Fallback to default
|
|
@@ -466,38 +464,33 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
466
464
|
self,
|
|
467
465
|
module_id: str,
|
|
468
466
|
processdata_id: str,
|
|
469
|
-
) -> Mapping[str, ProcessDataCollection]:
|
|
470
|
-
...
|
|
467
|
+
) -> Mapping[str, ProcessDataCollection]: ...
|
|
471
468
|
|
|
472
469
|
@overload
|
|
473
470
|
async def get_process_data_values(
|
|
474
471
|
self,
|
|
475
472
|
module_id: str,
|
|
476
473
|
processdata_id: Iterable[str],
|
|
477
|
-
) -> Mapping[str, ProcessDataCollection]:
|
|
478
|
-
...
|
|
474
|
+
) -> Mapping[str, ProcessDataCollection]: ...
|
|
479
475
|
|
|
480
476
|
@overload
|
|
481
477
|
async def get_process_data_values(
|
|
482
478
|
self,
|
|
483
479
|
module_id: str,
|
|
484
|
-
) -> Mapping[str, ProcessDataCollection]:
|
|
485
|
-
...
|
|
480
|
+
) -> Mapping[str, ProcessDataCollection]: ...
|
|
486
481
|
|
|
487
482
|
@overload
|
|
488
483
|
async def get_process_data_values(
|
|
489
484
|
self,
|
|
490
485
|
module_id: Mapping[str, Iterable[str]],
|
|
491
|
-
) -> Mapping[str, ProcessDataCollection]:
|
|
492
|
-
...
|
|
486
|
+
) -> Mapping[str, ProcessDataCollection]: ...
|
|
493
487
|
|
|
494
488
|
@overload
|
|
495
489
|
async def get_process_data_values(
|
|
496
490
|
self,
|
|
497
491
|
module_id: Union[str, Mapping[str, Iterable[str]]],
|
|
498
492
|
processdata_id: Union[str, Iterable[str], None] = None,
|
|
499
|
-
) -> Mapping[str, ProcessDataCollection]:
|
|
500
|
-
...
|
|
493
|
+
) -> Mapping[str, ProcessDataCollection]: ...
|
|
501
494
|
|
|
502
495
|
@_relogin
|
|
503
496
|
async def get_process_data_values(
|
|
@@ -523,7 +516,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
523
516
|
data_response = await resp.json()
|
|
524
517
|
return {
|
|
525
518
|
data_response[0]["moduleid"]: ProcessDataCollection(
|
|
526
|
-
|
|
519
|
+
process_data_list(data_response[0]["processdata"])
|
|
527
520
|
)
|
|
528
521
|
}
|
|
529
522
|
|
|
@@ -536,7 +529,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
536
529
|
data_response = await resp.json()
|
|
537
530
|
return {
|
|
538
531
|
data_response[0]["moduleid"]: ProcessDataCollection(
|
|
539
|
-
|
|
532
|
+
process_data_list(data_response[0]["processdata"])
|
|
540
533
|
)
|
|
541
534
|
}
|
|
542
535
|
|
|
@@ -552,7 +545,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
552
545
|
data_response = await resp.json()
|
|
553
546
|
return {
|
|
554
547
|
data_response[0]["moduleid"]: ProcessDataCollection(
|
|
555
|
-
|
|
548
|
+
process_data_list(data_response[0]["processdata"])
|
|
556
549
|
)
|
|
557
550
|
}
|
|
558
551
|
|
|
@@ -562,7 +555,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
562
555
|
for mid, pids in module_id.items():
|
|
563
556
|
# the json encoder expects that iterables are either list or tuples,
|
|
564
557
|
# other types has to be converted
|
|
565
|
-
if isinstance(pids, list
|
|
558
|
+
if isinstance(pids, (list, tuple)):
|
|
566
559
|
request.append(dict(moduleid=mid, processdataids=pids))
|
|
567
560
|
else:
|
|
568
561
|
request.append(dict(moduleid=mid, processdataids=list(pids)))
|
|
@@ -574,7 +567,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
574
567
|
data_response = await resp.json()
|
|
575
568
|
return {
|
|
576
569
|
x["moduleid"]: ProcessDataCollection(
|
|
577
|
-
|
|
570
|
+
process_data_list(x["processdata"])
|
|
578
571
|
)
|
|
579
572
|
for x in data_response
|
|
580
573
|
}
|
|
@@ -588,9 +581,9 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
588
581
|
response = await resp.json()
|
|
589
582
|
result: Dict[str, List[SettingsData]] = {}
|
|
590
583
|
for module in response:
|
|
591
|
-
|
|
592
|
-
data =
|
|
593
|
-
result[
|
|
584
|
+
mid = module["moduleid"]
|
|
585
|
+
data = [SettingsData(**x) for x in module["settings"]]
|
|
586
|
+
result[mid] = data
|
|
594
587
|
|
|
595
588
|
return result
|
|
596
589
|
|
|
@@ -599,30 +592,26 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
599
592
|
self,
|
|
600
593
|
module_id: str,
|
|
601
594
|
setting_id: str,
|
|
602
|
-
) -> Mapping[str, Mapping[str, str]]:
|
|
603
|
-
...
|
|
595
|
+
) -> Mapping[str, Mapping[str, str]]: ...
|
|
604
596
|
|
|
605
597
|
@overload
|
|
606
598
|
async def get_setting_values(
|
|
607
599
|
self,
|
|
608
600
|
module_id: str,
|
|
609
601
|
setting_id: Iterable[str],
|
|
610
|
-
) -> Mapping[str, Mapping[str, str]]:
|
|
611
|
-
...
|
|
602
|
+
) -> Mapping[str, Mapping[str, str]]: ...
|
|
612
603
|
|
|
613
604
|
@overload
|
|
614
605
|
async def get_setting_values(
|
|
615
606
|
self,
|
|
616
607
|
module_id: str,
|
|
617
|
-
) -> Mapping[str, Mapping[str, str]]:
|
|
618
|
-
...
|
|
608
|
+
) -> Mapping[str, Mapping[str, str]]: ...
|
|
619
609
|
|
|
620
610
|
@overload
|
|
621
611
|
async def get_setting_values(
|
|
622
612
|
self,
|
|
623
613
|
module_id: Mapping[str, Iterable[str]],
|
|
624
|
-
) -> Mapping[str, Mapping[str, str]]:
|
|
625
|
-
...
|
|
614
|
+
) -> Mapping[str, Mapping[str, str]]: ...
|
|
626
615
|
|
|
627
616
|
@_relogin
|
|
628
617
|
async def get_setting_values(
|
|
@@ -672,12 +661,11 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
672
661
|
for mid, pids in module_id.items():
|
|
673
662
|
# the json encoder expects that iterables are either list or tuples,
|
|
674
663
|
# other types has to be converted
|
|
675
|
-
if isinstance(pids, list
|
|
664
|
+
if isinstance(pids, (list, tuple)):
|
|
676
665
|
request.append(dict(moduleid=mid, settingids=pids))
|
|
677
666
|
else:
|
|
678
667
|
request.append(dict(moduleid=mid, settingids=list(pids)))
|
|
679
668
|
|
|
680
|
-
|
|
681
669
|
async with self._session_request(
|
|
682
670
|
"settings", method="POST", json=request
|
|
683
671
|
) as resp:
|
|
@@ -696,7 +684,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
|
696
684
|
request = [
|
|
697
685
|
{
|
|
698
686
|
"moduleid": module_id,
|
|
699
|
-
"settings":
|
|
687
|
+
"settings": [dict(value=v, id=k) for k, v in values.items()],
|
|
700
688
|
}
|
|
701
689
|
]
|
|
702
690
|
async with self._session_request(
|
pykoplenti/cli.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
from ast import literal_eval
|
|
2
2
|
import asyncio
|
|
3
3
|
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from inspect import iscoroutinefunction
|
|
5
6
|
import os
|
|
7
|
+
from pathlib import Path
|
|
6
8
|
from pprint import pprint
|
|
7
9
|
import re
|
|
8
10
|
import tempfile
|
|
9
11
|
import traceback
|
|
10
|
-
from typing import Any, Awaitable, Callable, Dict, Union
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Union
|
|
13
|
+
import warnings
|
|
11
14
|
|
|
12
15
|
from aiohttp import ClientSession, ClientTimeout
|
|
13
16
|
import click
|
|
@@ -20,40 +23,43 @@ from pykoplenti.extended import ExtendedApiClient
|
|
|
20
23
|
class SessionCache:
|
|
21
24
|
"""Persistent the session in a temporary file."""
|
|
22
25
|
|
|
23
|
-
def __init__(self, host):
|
|
24
|
-
self.
|
|
26
|
+
def __init__(self, host: str, user: str):
|
|
27
|
+
self._cache_file = Path(
|
|
28
|
+
tempfile.gettempdir(), f"pykoplenti-session-{host}-{user}"
|
|
29
|
+
)
|
|
25
30
|
|
|
26
31
|
def read_session_id(self) -> Union[str, None]:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
with open(file, "rt") as f:
|
|
32
|
+
if self._cache_file.is_file():
|
|
33
|
+
with self._cache_file.open("rt") as f:
|
|
30
34
|
return f.readline(256)
|
|
31
35
|
else:
|
|
32
36
|
return None
|
|
33
37
|
|
|
34
38
|
def write_session_id(self, id: str):
|
|
35
|
-
|
|
36
|
-
f = os.open(file, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode=0o600)
|
|
39
|
+
f = os.open(self._cache_file, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode=0o600)
|
|
37
40
|
try:
|
|
38
41
|
os.write(f, id.encode("ascii"))
|
|
39
42
|
finally:
|
|
40
43
|
os.close(f)
|
|
41
44
|
|
|
45
|
+
def remove(self):
|
|
46
|
+
self._cache_file.unlink(missing_ok=True)
|
|
47
|
+
|
|
42
48
|
|
|
43
49
|
class ApiShell:
|
|
44
50
|
"""Provides a shell-like access to the inverter."""
|
|
45
51
|
|
|
46
|
-
def __init__(self, client: ApiClient):
|
|
52
|
+
def __init__(self, client: ApiClient, user: str):
|
|
47
53
|
super().__init__()
|
|
48
54
|
self.client = client
|
|
49
|
-
self._session_cache = SessionCache(self.client.host)
|
|
55
|
+
self._session_cache = SessionCache(self.client.host, user)
|
|
50
56
|
|
|
51
|
-
async def prepare_client(self,
|
|
57
|
+
async def prepare_client(self, key: Optional[str], service_code: Optional[str]):
|
|
52
58
|
# first try to reuse existing session
|
|
53
59
|
session_id = self._session_cache.read_session_id()
|
|
54
60
|
if session_id is not None:
|
|
55
61
|
self.client.session_id = session_id
|
|
56
|
-
print_formatted_text("Trying to reuse existing session... ", end=
|
|
62
|
+
print_formatted_text("Trying to reuse existing session... ", end="")
|
|
57
63
|
me = await self.client.get_me()
|
|
58
64
|
if me.is_authenticated:
|
|
59
65
|
print_formatted_text("Success")
|
|
@@ -61,18 +67,21 @@ class ApiShell:
|
|
|
61
67
|
|
|
62
68
|
print_formatted_text("Failed")
|
|
63
69
|
|
|
64
|
-
if
|
|
65
|
-
print_formatted_text("Logging in... ", end=
|
|
66
|
-
await self.client.login(
|
|
67
|
-
self.
|
|
70
|
+
if key is not None:
|
|
71
|
+
print_formatted_text("Logging in... ", end="")
|
|
72
|
+
await self.client.login(key=key, service_code=service_code)
|
|
73
|
+
if self.client.session_id is not None:
|
|
74
|
+
self._session_cache.write_session_id(self.client.session_id)
|
|
68
75
|
print_formatted_text("Success")
|
|
76
|
+
else:
|
|
77
|
+
print_formatted_text("Session could not be reused and no key given")
|
|
69
78
|
|
|
70
79
|
def print_exception(self):
|
|
71
80
|
"""Prints an excpetion from executing a method."""
|
|
72
81
|
print_formatted_text(traceback.format_exc())
|
|
73
82
|
|
|
74
|
-
async def run(self,
|
|
75
|
-
session = PromptSession()
|
|
83
|
+
async def run(self, key: Optional[str], service_code: Optional[str]):
|
|
84
|
+
session = PromptSession[str]()
|
|
76
85
|
print_formatted_text(flush=True) # Initialize output
|
|
77
86
|
|
|
78
87
|
# Test commands:
|
|
@@ -83,7 +92,7 @@ class ApiShell:
|
|
|
83
92
|
# get_setting_values 'scb:time'
|
|
84
93
|
# set_setting_values 'devices:local' {'Battery:MinSoc':'15'}
|
|
85
94
|
|
|
86
|
-
await self.prepare_client(
|
|
95
|
+
await self.prepare_client(key, service_code)
|
|
87
96
|
|
|
88
97
|
while True:
|
|
89
98
|
try:
|
|
@@ -153,82 +162,171 @@ class ApiShell:
|
|
|
153
162
|
self.print_exception()
|
|
154
163
|
|
|
155
164
|
|
|
156
|
-
async def repl_main(
|
|
165
|
+
async def repl_main(
|
|
166
|
+
host: str, port: int, key: Optional[str], service_code: Optional[str]
|
|
167
|
+
):
|
|
157
168
|
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
|
|
158
169
|
client = ExtendedApiClient(session, host=host, port=port)
|
|
159
170
|
|
|
160
|
-
shell = ApiShell(client)
|
|
161
|
-
await shell.run(
|
|
171
|
+
shell = ApiShell(client, "user" if service_code is None else "master")
|
|
172
|
+
await shell.run(key, service_code)
|
|
162
173
|
|
|
163
174
|
|
|
164
175
|
async def command_main(
|
|
165
|
-
host: str,
|
|
176
|
+
host: str,
|
|
177
|
+
port: int,
|
|
178
|
+
key: Optional[str],
|
|
179
|
+
service_code: Optional[str],
|
|
180
|
+
fn: Callable[[ApiClient], Awaitable[Any]],
|
|
166
181
|
):
|
|
167
182
|
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
|
|
168
183
|
client = ExtendedApiClient(session, host=host, port=port)
|
|
169
|
-
session_cache = SessionCache(host)
|
|
184
|
+
session_cache = SessionCache(host, "user" if service_code is None else "master")
|
|
170
185
|
|
|
171
186
|
# Try to reuse an existing session
|
|
172
187
|
client.session_id = session_cache.read_session_id()
|
|
173
188
|
me = await client.get_me()
|
|
174
189
|
if not me.is_authenticated:
|
|
190
|
+
if key is None:
|
|
191
|
+
raise ValueError("Could not reuse session and no login key is given.")
|
|
192
|
+
|
|
175
193
|
# create a new session
|
|
176
|
-
await client.login(
|
|
194
|
+
await client.login(key=key, service_code=service_code)
|
|
195
|
+
|
|
177
196
|
if client.session_id is not None:
|
|
178
197
|
session_cache.write_session_id(client.session_id)
|
|
179
198
|
|
|
180
199
|
await fn(client)
|
|
181
200
|
|
|
182
201
|
|
|
202
|
+
@dataclass
|
|
183
203
|
class GlobalArgs:
|
|
184
204
|
"""Global arguments over all sub commands."""
|
|
185
205
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
206
|
+
host: str = ""
|
|
207
|
+
"""The hostname or ip of the inverter."""
|
|
208
|
+
|
|
209
|
+
port: int = 0
|
|
210
|
+
"""The port on which the API listens on the inverter."""
|
|
211
|
+
|
|
212
|
+
key: Optional[str] = None
|
|
213
|
+
"""The key (password or master key) to login into the API.
|
|
214
|
+
|
|
215
|
+
If None, a previous session cache is used. If the session
|
|
216
|
+
cache has no valid session, no login is executed.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
service_code: Optional[str] = None
|
|
220
|
+
"""The service code for master access.
|
|
221
|
+
|
|
222
|
+
Only necessary for master access. If missing, user acess is used.
|
|
223
|
+
"""
|
|
191
224
|
|
|
192
225
|
|
|
193
226
|
pass_global_args = click.make_pass_decorator(GlobalArgs, ensure=True)
|
|
194
227
|
|
|
195
228
|
|
|
229
|
+
def _parse_credentials_file(path: Path) -> tuple[Optional[str], Optional[str]]:
|
|
230
|
+
"""Parse credentials file returning (key, service_code)"""
|
|
231
|
+
key = service_code = None
|
|
232
|
+
for line in path.read_text().splitlines():
|
|
233
|
+
if "=" not in line:
|
|
234
|
+
return line.strip(), None
|
|
235
|
+
|
|
236
|
+
name, _, value = line.partition("=")
|
|
237
|
+
name = name.strip()
|
|
238
|
+
if name in ("password", "key", "master-key"):
|
|
239
|
+
key = value.strip()
|
|
240
|
+
elif name == "service-code":
|
|
241
|
+
service_code = value.strip()
|
|
242
|
+
return key, service_code
|
|
243
|
+
|
|
244
|
+
|
|
196
245
|
@click.group()
|
|
197
|
-
@click.option("--host", help="
|
|
198
|
-
@click.option("--port", default=80, help="
|
|
199
|
-
@click.option(
|
|
246
|
+
@click.option("--host", help="Hostname or IP of the inverter")
|
|
247
|
+
@click.option("--port", default=80, help="Port of the inverter", show_default=True)
|
|
248
|
+
@click.option(
|
|
249
|
+
"--password", default=None, help="Password or master key (also device id)"
|
|
250
|
+
)
|
|
251
|
+
@click.option("--service-code", default=None, help="service code for installer access")
|
|
200
252
|
@click.option(
|
|
201
253
|
"--password-file",
|
|
202
254
|
default="secrets",
|
|
203
|
-
help=
|
|
255
|
+
help="Path to password file - deprecated, use --credentials",
|
|
256
|
+
show_default=True,
|
|
257
|
+
type=click.Path(exists=False, dir_okay=False, readable=True, path_type=Path),
|
|
258
|
+
)
|
|
259
|
+
@click.option(
|
|
260
|
+
"--credentials",
|
|
261
|
+
default=None,
|
|
262
|
+
help="Path to the credentials file. This has a simple ini-format without sections. "
|
|
263
|
+
"For user access, use the 'password'. For installer access, use the 'master-key' "
|
|
264
|
+
"and 'service-key'.",
|
|
265
|
+
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
|
|
204
266
|
)
|
|
205
267
|
@pass_global_args
|
|
206
|
-
def cli(
|
|
268
|
+
def cli(
|
|
269
|
+
global_args: GlobalArgs,
|
|
270
|
+
host: str,
|
|
271
|
+
port: int,
|
|
272
|
+
password: Optional[str],
|
|
273
|
+
service_code: Optional[str],
|
|
274
|
+
password_file: Path,
|
|
275
|
+
credentials: Path,
|
|
276
|
+
):
|
|
207
277
|
"""Handling of global arguments with click"""
|
|
208
|
-
if password is not None:
|
|
209
|
-
global_args.passwd = password
|
|
210
|
-
elif os.path.isfile(password_file):
|
|
211
|
-
with open(password_file, "rt") as f:
|
|
212
|
-
global_args.passwd = f.readline()
|
|
213
|
-
else:
|
|
214
|
-
global_args.passwd = None
|
|
215
|
-
|
|
216
278
|
global_args.host = host
|
|
217
279
|
global_args.port = port
|
|
218
280
|
|
|
281
|
+
if password is not None:
|
|
282
|
+
global_args.key = password
|
|
283
|
+
elif password_file.is_file():
|
|
284
|
+
with password_file.open("rt") as f:
|
|
285
|
+
global_args.key = f.readline()
|
|
286
|
+
warnings.warn(
|
|
287
|
+
"--password-file is deprecated. Use --credentials instead.",
|
|
288
|
+
DeprecationWarning,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if service_code is not None:
|
|
292
|
+
global_args.service_code = service_code
|
|
293
|
+
|
|
294
|
+
if credentials is not None:
|
|
295
|
+
if password is not None:
|
|
296
|
+
raise click.BadOptionUsage(
|
|
297
|
+
"password", "password cannot be used with credentials"
|
|
298
|
+
)
|
|
299
|
+
if password_file is not None and password_file.is_file():
|
|
300
|
+
raise click.BadOptionUsage(
|
|
301
|
+
"password-file", "password-file cannot be used with credentials"
|
|
302
|
+
)
|
|
303
|
+
if service_code is not None:
|
|
304
|
+
raise click.BadOptionUsage(
|
|
305
|
+
"service_code", "service_code cannot be used with credentials"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
global_args.key, global_args.service_code = _parse_credentials_file(credentials)
|
|
309
|
+
|
|
219
310
|
|
|
220
311
|
@cli.command()
|
|
221
312
|
@pass_global_args
|
|
222
|
-
def repl(global_args):
|
|
313
|
+
def repl(global_args: GlobalArgs):
|
|
223
314
|
"""Provides a simple REPL for executing API requests to the inverter."""
|
|
224
|
-
asyncio.run(
|
|
315
|
+
asyncio.run(
|
|
316
|
+
repl_main(
|
|
317
|
+
global_args.host,
|
|
318
|
+
global_args.port,
|
|
319
|
+
global_args.key,
|
|
320
|
+
global_args.service_code,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
225
323
|
|
|
226
324
|
|
|
227
325
|
@cli.command()
|
|
228
326
|
@click.option("--lang", default=None, help="language for events")
|
|
229
327
|
@click.option("--count", default=10, help="number of events to read")
|
|
230
328
|
@pass_global_args
|
|
231
|
-
def read_events(global_args, lang, count):
|
|
329
|
+
def read_events(global_args: GlobalArgs, lang, count):
|
|
232
330
|
"""Returns the last events"""
|
|
233
331
|
|
|
234
332
|
async def fn(client: ApiClient):
|
|
@@ -236,11 +334,17 @@ def read_events(global_args, lang, count):
|
|
|
236
334
|
for event in data:
|
|
237
335
|
print(
|
|
238
336
|
f"{event.is_active < 5} {event.start_time} {event.end_time} "
|
|
239
|
-
"{event.description}"
|
|
337
|
+
f"{event.description}"
|
|
240
338
|
)
|
|
241
339
|
|
|
242
340
|
asyncio.run(
|
|
243
|
-
command_main(
|
|
341
|
+
command_main(
|
|
342
|
+
global_args.host,
|
|
343
|
+
global_args.port,
|
|
344
|
+
global_args.key,
|
|
345
|
+
global_args.service_code,
|
|
346
|
+
fn,
|
|
347
|
+
)
|
|
244
348
|
)
|
|
245
349
|
|
|
246
350
|
|
|
@@ -254,20 +358,26 @@ def read_events(global_args, lang, count):
|
|
|
254
358
|
@click.option("--begin", type=click.DateTime(["%Y-%m-%d"]), help="first day to export")
|
|
255
359
|
@click.option("--end", type=click.DateTime(["%Y-%m-%d"]), help="last day to export")
|
|
256
360
|
@pass_global_args
|
|
257
|
-
def download_log(global_args, out, begin, end):
|
|
361
|
+
def download_log(global_args: GlobalArgs, out, begin, end):
|
|
258
362
|
"""Download the log data from the inverter to a file."""
|
|
259
363
|
|
|
260
364
|
async def fn(client: ApiClient):
|
|
261
365
|
await client.download_logdata(writer=out, begin=begin, end=end)
|
|
262
366
|
|
|
263
367
|
asyncio.run(
|
|
264
|
-
command_main(
|
|
368
|
+
command_main(
|
|
369
|
+
global_args.host,
|
|
370
|
+
global_args.port,
|
|
371
|
+
global_args.key,
|
|
372
|
+
global_args.service_code,
|
|
373
|
+
fn,
|
|
374
|
+
)
|
|
265
375
|
)
|
|
266
376
|
|
|
267
377
|
|
|
268
378
|
@cli.command()
|
|
269
379
|
@pass_global_args
|
|
270
|
-
def all_processdata(global_args):
|
|
380
|
+
def all_processdata(global_args: GlobalArgs):
|
|
271
381
|
"""Returns a list of all available process data."""
|
|
272
382
|
|
|
273
383
|
async def fn(client: ApiClient):
|
|
@@ -277,14 +387,20 @@ def all_processdata(global_args):
|
|
|
277
387
|
print(f"{k}/{x}")
|
|
278
388
|
|
|
279
389
|
asyncio.run(
|
|
280
|
-
command_main(
|
|
390
|
+
command_main(
|
|
391
|
+
global_args.host,
|
|
392
|
+
global_args.port,
|
|
393
|
+
global_args.key,
|
|
394
|
+
global_args.service_code,
|
|
395
|
+
fn,
|
|
396
|
+
)
|
|
281
397
|
)
|
|
282
398
|
|
|
283
399
|
|
|
284
400
|
@cli.command()
|
|
285
401
|
@click.argument("ids", required=True, nargs=-1)
|
|
286
402
|
@pass_global_args
|
|
287
|
-
def read_processdata(global_args, ids):
|
|
403
|
+
def read_processdata(global_args: GlobalArgs, ids):
|
|
288
404
|
"""Returns the values of the given process data.
|
|
289
405
|
|
|
290
406
|
IDS is the identifier (<module_id>/<processdata_id>) of one or more processdata
|
|
@@ -318,7 +434,13 @@ def read_processdata(global_args, ids):
|
|
|
318
434
|
print(f"{k}/{x.id}={x.value}")
|
|
319
435
|
|
|
320
436
|
asyncio.run(
|
|
321
|
-
command_main(
|
|
437
|
+
command_main(
|
|
438
|
+
global_args.host,
|
|
439
|
+
global_args.port,
|
|
440
|
+
global_args.key,
|
|
441
|
+
global_args.service_code,
|
|
442
|
+
fn,
|
|
443
|
+
)
|
|
322
444
|
)
|
|
323
445
|
|
|
324
446
|
|
|
@@ -327,7 +449,7 @@ def read_processdata(global_args, ids):
|
|
|
327
449
|
"--rw", is_flag=True, default=False, help="display only writable settings"
|
|
328
450
|
)
|
|
329
451
|
@pass_global_args
|
|
330
|
-
def all_settings(global_args, rw):
|
|
452
|
+
def all_settings(global_args: GlobalArgs, rw: bool):
|
|
331
453
|
"""Returns the ids of all settings."""
|
|
332
454
|
|
|
333
455
|
async def fn(client: ApiClient):
|
|
@@ -338,14 +460,20 @@ def all_settings(global_args, rw):
|
|
|
338
460
|
print(f"{k}/{x.id}")
|
|
339
461
|
|
|
340
462
|
asyncio.run(
|
|
341
|
-
command_main(
|
|
463
|
+
command_main(
|
|
464
|
+
global_args.host,
|
|
465
|
+
global_args.port,
|
|
466
|
+
global_args.key,
|
|
467
|
+
global_args.service_code,
|
|
468
|
+
fn,
|
|
469
|
+
)
|
|
342
470
|
)
|
|
343
471
|
|
|
344
472
|
|
|
345
473
|
@cli.command()
|
|
346
474
|
@click.argument("ids", required=True, nargs=-1)
|
|
347
475
|
@pass_global_args
|
|
348
|
-
def read_settings(global_args, ids):
|
|
476
|
+
def read_settings(global_args: GlobalArgs, ids):
|
|
349
477
|
"""Read the value of the given settings.
|
|
350
478
|
|
|
351
479
|
IDS is the identifier (<module_id>/<setting_id>) of one or more settings to read
|
|
@@ -376,14 +504,20 @@ def read_settings(global_args, ids):
|
|
|
376
504
|
print(f"{k}/{i}={v}")
|
|
377
505
|
|
|
378
506
|
asyncio.run(
|
|
379
|
-
command_main(
|
|
507
|
+
command_main(
|
|
508
|
+
global_args.host,
|
|
509
|
+
global_args.port,
|
|
510
|
+
global_args.key,
|
|
511
|
+
global_args.service_code,
|
|
512
|
+
fn,
|
|
513
|
+
)
|
|
380
514
|
)
|
|
381
515
|
|
|
382
516
|
|
|
383
517
|
@cli.command()
|
|
384
518
|
@click.argument("id_values", required=True, nargs=-1)
|
|
385
519
|
@pass_global_args
|
|
386
|
-
def write_settings(global_args, id_values):
|
|
520
|
+
def write_settings(global_args: GlobalArgs, id_values):
|
|
387
521
|
"""Write the values of the given settings.
|
|
388
522
|
|
|
389
523
|
ID_VALUES is the identifier plus the the value to write
|
|
@@ -412,7 +546,13 @@ def write_settings(global_args, id_values):
|
|
|
412
546
|
await client.set_setting_values(module_id, setting_values)
|
|
413
547
|
|
|
414
548
|
asyncio.run(
|
|
415
|
-
command_main(
|
|
549
|
+
command_main(
|
|
550
|
+
global_args.host,
|
|
551
|
+
global_args.port,
|
|
552
|
+
global_args.key,
|
|
553
|
+
global_args.service_code,
|
|
554
|
+
fn,
|
|
555
|
+
)
|
|
416
556
|
)
|
|
417
557
|
|
|
418
558
|
|
pykoplenti/model.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
-
from typing import Iterator, Mapping
|
|
2
|
+
from typing import Final, Iterator, Mapping, Optional
|
|
3
3
|
|
|
4
|
+
import pydantic
|
|
4
5
|
from pydantic import BaseModel, Field
|
|
5
6
|
|
|
6
7
|
|
|
@@ -55,7 +56,7 @@ class ProcessDataCollection(Mapping):
|
|
|
55
56
|
try:
|
|
56
57
|
return next(x for x in self._process_data if x.id == item)
|
|
57
58
|
except StopIteration:
|
|
58
|
-
raise KeyError(item)
|
|
59
|
+
raise KeyError(item) from None
|
|
59
60
|
|
|
60
61
|
def __eq__(self, __other: object) -> bool:
|
|
61
62
|
if not isinstance(__other, ProcessDataCollection):
|
|
@@ -77,11 +78,11 @@ class ProcessDataCollection(Mapping):
|
|
|
77
78
|
class SettingsData(BaseModel):
|
|
78
79
|
"""Represents a single settings data."""
|
|
79
80
|
|
|
80
|
-
min: str
|
|
81
|
-
max: str
|
|
82
|
-
default: str
|
|
81
|
+
min: Optional[str]
|
|
82
|
+
max: Optional[str]
|
|
83
|
+
default: Optional[str]
|
|
83
84
|
access: str
|
|
84
|
-
unit: str
|
|
85
|
+
unit: Optional[str]
|
|
85
86
|
id: str
|
|
86
87
|
type: str
|
|
87
88
|
|
|
@@ -97,3 +98,24 @@ class EventData(BaseModel):
|
|
|
97
98
|
description: str
|
|
98
99
|
group: str
|
|
99
100
|
is_active: bool
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# pydantic version specific code
|
|
104
|
+
# In pydantic 2.x `parse_obj_as` is no longer supported. To stay compatible to
|
|
105
|
+
# both version a small wrapper function is used.
|
|
106
|
+
|
|
107
|
+
if pydantic.VERSION.startswith("2."):
|
|
108
|
+
from pydantic import TypeAdapter
|
|
109
|
+
|
|
110
|
+
_process_list_adapter: Final = TypeAdapter(list[ProcessData])
|
|
111
|
+
|
|
112
|
+
def process_data_list(json) -> list[ProcessData]:
|
|
113
|
+
"""Process json as a list of ProcessData objects."""
|
|
114
|
+
return _process_list_adapter.validate_python(json)
|
|
115
|
+
|
|
116
|
+
else:
|
|
117
|
+
from pydantic import parse_obj_as
|
|
118
|
+
|
|
119
|
+
def process_data_list(json) -> list[ProcessData]:
|
|
120
|
+
"""Process json as a list of ProcessData objects."""
|
|
121
|
+
return parse_obj_as(list[ProcessData], json)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pykoplenti
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Python REST-Client for Kostal Plenticore Solar Inverters
|
|
5
5
|
Home-page: https://github.com/stegm/pyclient_koplenti
|
|
6
6
|
Author: @stegm
|
|
@@ -13,17 +13,20 @@ Classifier: Environment :: Console
|
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
19
21
|
Description-Content-Type: text/markdown
|
|
20
22
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: aiohttp
|
|
22
|
-
Requires-Dist: pycryptodome
|
|
23
|
-
Requires-Dist: pydantic
|
|
23
|
+
Requires-Dist: aiohttp~=3.8
|
|
24
|
+
Requires-Dist: pycryptodome~=3.19
|
|
25
|
+
Requires-Dist: pydantic>=1.10
|
|
24
26
|
Provides-Extra: cli
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: click
|
|
27
|
+
Requires-Dist: prompt_toolkit>=3.0; extra == "cli"
|
|
28
|
+
Requires-Dist: click>=8.0; extra == "cli"
|
|
29
|
+
Dynamic: license-file
|
|
27
30
|
|
|
28
31
|
# Python Library for Accessing Kostal Plenticore Inverters
|
|
29
32
|
|
|
@@ -75,22 +78,28 @@ Installing the libray with `CLI` provides a new command.
|
|
|
75
78
|
|
|
76
79
|
```shell
|
|
77
80
|
$ pykoplenti --help
|
|
78
|
-
Usage: pykoplenti [OPTIONS] COMMAND [ARGS]...
|
|
81
|
+
Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]...
|
|
79
82
|
|
|
80
83
|
Handling of global arguments with click
|
|
81
84
|
|
|
82
85
|
Options:
|
|
83
|
-
--host TEXT
|
|
84
|
-
--port INTEGER
|
|
85
|
-
--password TEXT
|
|
86
|
-
--
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
--host TEXT Hostname or IP of the inverter
|
|
87
|
+
--port INTEGER Port of the inverter [default: 80]
|
|
88
|
+
--password TEXT Password or master key (also device id)
|
|
89
|
+
--service-code TEXT service code for installer access
|
|
90
|
+
--password-file FILE Path to password file - deprecated, use --credentials
|
|
91
|
+
[default: secrets]
|
|
92
|
+
--credentials FILE Path to the credentials file. This has a simple ini-
|
|
93
|
+
format without sections. For user access, use the
|
|
94
|
+
'password'. For installer access, use the 'master-key'
|
|
95
|
+
and 'service-key'.
|
|
89
96
|
--help Show this message and exit.
|
|
90
97
|
|
|
91
98
|
Commands:
|
|
92
99
|
all-processdata Returns a list of all available process data.
|
|
93
100
|
all-settings Returns the ids of all settings.
|
|
101
|
+
download-log Download the log data from the inverter to a file.
|
|
102
|
+
read-events Returns the last events
|
|
94
103
|
read-processdata Returns the values of the given process data.
|
|
95
104
|
read-settings Read the value of the given settings.
|
|
96
105
|
repl Provides a simple REPL for executing API requests to...
|
|
@@ -155,9 +164,11 @@ await client.login(my_master_key, service_code=my_service_code)
|
|
|
155
164
|
- [click](https://click.palletsprojects.com/) - command line interface framework
|
|
156
165
|
- [black](https://github.com/psf/black) - Python code formatter
|
|
157
166
|
- [ruff](https://github.com/astral-sh/ruff) - Python linter
|
|
167
|
+
- [pydantic](https://docs.pydantic.dev/latest/) - Data validation library
|
|
158
168
|
- [pytest](https://docs.pytest.org/) - Python test framework
|
|
159
169
|
- [mypy](https://mypy-lang.org/) - Python type checker
|
|
160
170
|
- [setuptools](https://github.com/pypa/setuptools) - Python packager
|
|
171
|
+
- [tox](https://tox.wiki) - Automate testing
|
|
161
172
|
|
|
162
173
|
## License
|
|
163
174
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pykoplenti/__init__.py,sha256=w9ooy6_JaT9lGvMDAaBHyvYu1uTFTkb1g21WRxp3Kzw,756
|
|
2
|
+
pykoplenti/api.py,sha256=ViT25KQt3caH78fO30G014IawcWQ1lMvy28M9QLBypg,26111
|
|
3
|
+
pykoplenti/cli.py,sha256=AIc2sdzEAhexYwU8C9UTQvKtu0gPYiEnz8fJDcEILI4,17042
|
|
4
|
+
pykoplenti/extended.py,sha256=_MCDtP-6BaAiTFqJ44CLK_Ihkh6nCC0vWWv1lsQfEFs,9311
|
|
5
|
+
pykoplenti/model.py,sha256=lFOHDJvWyhOdQrcoun6HeT-4XaGY5I2gy1j6M5u3u6A,3016
|
|
6
|
+
pykoplenti/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pykoplenti-1.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
8
|
+
pykoplenti-1.4.0.dist-info/METADATA,sha256=elvtTQ9SMFTl29_j_WHUaboSpmdlCc49T1iv7ciH5Ms,6356
|
|
9
|
+
pykoplenti-1.4.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
10
|
+
pykoplenti-1.4.0.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
|
|
11
|
+
pykoplenti-1.4.0.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
|
|
12
|
+
pykoplenti-1.4.0.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
pykoplenti/__init__.py,sha256=w9ooy6_JaT9lGvMDAaBHyvYu1uTFTkb1g21WRxp3Kzw,756
|
|
2
|
-
pykoplenti/api.py,sha256=gYWWd8yZ9winj7qPt1-rzx4stX_DLPfy5nOduuamtH4,26431
|
|
3
|
-
pykoplenti/cli.py,sha256=LAiQHlSgoJz07kTtFh0bNyahyYz7gCenhRfradex5wE,12972
|
|
4
|
-
pykoplenti/extended.py,sha256=_MCDtP-6BaAiTFqJ44CLK_Ihkh6nCC0vWWv1lsQfEFs,9311
|
|
5
|
-
pykoplenti/model.py,sha256=g-KyYTF1M1p6OAebyA74OAP_-561u6Hylhgy_jnpMto,2266
|
|
6
|
-
pykoplenti/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
pykoplenti-1.2.2.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
8
|
-
pykoplenti-1.2.2.dist-info/METADATA,sha256=YCvoky9ow6vbvRA_qTQpbkm3hMHvMAwnpewjjQmBuGc,5625
|
|
9
|
-
pykoplenti-1.2.2.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
|
|
10
|
-
pykoplenti-1.2.2.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
|
|
11
|
-
pykoplenti-1.2.2.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
|
|
12
|
-
pykoplenti-1.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|