pykoplenti 1.0.0__py3-none-any.whl → 1.2.1__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/cli.py CHANGED
@@ -7,13 +7,14 @@ from pprint import pprint
7
7
  import re
8
8
  import tempfile
9
9
  import traceback
10
- from typing import Callable
10
+ from typing import Any, Awaitable, Callable, Dict, Union
11
11
 
12
12
  from aiohttp import ClientSession, ClientTimeout
13
13
  import click
14
14
  from prompt_toolkit import PromptSession, print_formatted_text
15
15
 
16
16
  from pykoplenti import ApiClient
17
+ from pykoplenti.extended import ExtendedApiClient
17
18
 
18
19
 
19
20
  class SessionCache:
@@ -22,7 +23,7 @@ class SessionCache:
22
23
  def __init__(self, host):
23
24
  self.host = host
24
25
 
25
- def read_session_id(self) -> str:
26
+ def read_session_id(self) -> Union[str, None]:
26
27
  file = os.path.join(tempfile.gettempdir(), f"pykoplenti-session-{self.host}")
27
28
  if os.path.isfile(file):
28
29
  with open(file, "rt") as f:
@@ -77,7 +78,8 @@ class ApiShell:
77
78
  # Test commands:
78
79
  # get_settings
79
80
  # get_setting_values 'devices:local' 'Battery:MinSoc'
80
- # get_setting_values 'devices:local' ['Battery:MinSoc','Battery:MinHomeComsumption']
81
+ # get_setting_values 'devices:local' ['Battery:MinSoc', \
82
+ # 'Battery:MinHomeComsumption']
81
83
  # get_setting_values 'scb:time'
82
84
  # set_setting_values 'devices:local' {'Battery:MinSoc':'15'}
83
85
 
@@ -93,7 +95,8 @@ class ApiShell:
93
95
  if text.strip() == "":
94
96
  continue
95
97
  else:
96
- # TODO split does not know about lists or dicts or strings with spaces
98
+ # TODO split does not know about lists or dicts or strings
99
+ # with spaces
97
100
  method_name, *arg_values = text.strip().split()
98
101
 
99
102
  if method_name == "help":
@@ -152,17 +155,17 @@ class ApiShell:
152
155
 
153
156
  async def repl_main(host, port, passwd):
154
157
  async with ClientSession(timeout=ClientTimeout(total=10)) as session:
155
- client = ApiClient(session, host=host, port=port)
158
+ client = ExtendedApiClient(session, host=host, port=port)
156
159
 
157
160
  shell = ApiShell(client)
158
161
  await shell.run(passwd)
159
162
 
160
163
 
161
164
  async def command_main(
162
- host: str, port: int, passwd: str, fn: Callable[[ApiClient], None]
165
+ host: str, port: int, passwd: str, fn: Callable[[ApiClient], Awaitable[Any]]
163
166
  ):
164
167
  async with ClientSession(timeout=ClientTimeout(total=10)) as session:
165
- client = ApiClient(session, host=host, port=port)
168
+ client = ExtendedApiClient(session, host=host, port=port)
166
169
  session_cache = SessionCache(host)
167
170
 
168
171
  # Try to reuse an existing session
@@ -171,7 +174,8 @@ async def command_main(
171
174
  if not me.is_authenticated:
172
175
  # create a new session
173
176
  await client.login(passwd)
174
- session_cache.write_session_id(client.session_id)
177
+ if client.session_id is not None:
178
+ session_cache.write_session_id(client.session_id)
175
179
 
176
180
  await fn(client)
177
181
 
@@ -231,7 +235,8 @@ def read_events(global_args, lang, count):
231
235
  data = await client.get_events(lang=lang, max_count=count)
232
236
  for event in data:
233
237
  print(
234
- f"{event.is_active < 5} {event.start_time} {event.end_time} {event.description}"
238
+ f"{event.is_active < 5} {event.start_time} {event.end_time} "
239
+ "{event.description}"
235
240
  )
236
241
 
237
242
  asyncio.run(
@@ -253,7 +258,7 @@ def download_log(global_args, out, begin, end):
253
258
  """Download the log data from the inverter to a file."""
254
259
 
255
260
  async def fn(client: ApiClient):
256
- data = await client.download_logdata(writer=out, begin=begin, end=end)
261
+ await client.download_logdata(writer=out, begin=begin, end=end)
257
262
 
258
263
  asyncio.run(
259
264
  command_main(global_args.host, global_args.port, global_args.passwd, fn)
@@ -309,7 +314,7 @@ def read_processdata(global_args, ids):
309
314
  values = await client.get_process_data_values(query)
310
315
 
311
316
  for k, v in values.items():
312
- for x in v:
317
+ for x in v.values():
313
318
  print(f"{k}/{x.id}={x.value}")
314
319
 
315
320
  asyncio.run(
@@ -348,7 +353,8 @@ def read_settings(global_args, ids):
348
353
  \b
349
354
  Examples:
350
355
  read-settings devices:local/Battery:MinSoc
351
- read-settings devices:local/Battery:MinSoc devices:local/Battery:MinHomeComsumption
356
+ read-settings devices:local/Battery:MinSoc \
357
+ devices:local/Battery:MinHomeComsumption
352
358
  """
353
359
 
354
360
  async def fn(client: ApiClient):
@@ -388,7 +394,7 @@ def write_settings(global_args, id_values):
388
394
  """
389
395
 
390
396
  async def fn(client: ApiClient):
391
- query = defaultdict(dict)
397
+ query: Dict[str, Dict[str, str]] = defaultdict(dict)
392
398
  for id_value in id_values:
393
399
  m = re.match(
394
400
  r"(?P<module_id>.+)/(?P<setting_id>.+)=(?P<value>.+)", id_value
@@ -414,4 +420,4 @@ def write_settings(global_args, id_values):
414
420
  if __name__ == "__main__":
415
421
  import sys
416
422
 
417
- cli(sys.argv[1:])
423
+ cli(sys.argv[1:], auto_envvar_prefix="PYKOPLENTI")
pykoplenti/extended.py ADDED
@@ -0,0 +1,239 @@
1
+ """Extended ApiClient which provides virtual process data values."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections import ChainMap, defaultdict
5
+ from typing import Final, Iterable, Literal, Mapping, MutableMapping, Union
6
+
7
+ from aiohttp import ClientSession
8
+
9
+ from .api import ApiClient
10
+ from .model import ProcessData, ProcessDataCollection
11
+
12
+ _VIRT_MODUL_ID: Final = "_virt_"
13
+
14
+
15
+ class _VirtProcessDataItemBase(ABC):
16
+ """Base class for all virtual process data items."""
17
+
18
+ def __init__(self, processid: str, process_data: dict[str, set[str]]) -> None:
19
+ self.processid = processid
20
+ self.process_data = process_data
21
+ self.available_process_data: dict[str, set[str]] = {}
22
+
23
+ def update_actual_process_ids(
24
+ self, available_process_ids: Mapping[str, Iterable[str]]
25
+ ):
26
+ """Update which process data for this item are available."""
27
+ self.available_process_data.clear()
28
+ for module_id, process_ids in self.process_data.items():
29
+ if module_id in available_process_ids:
30
+ matching_process_ids = process_ids.intersection(
31
+ available_process_ids[module_id]
32
+ )
33
+ if len(matching_process_ids) > 0:
34
+ self.available_process_data[module_id] = matching_process_ids
35
+
36
+ @abstractmethod
37
+ def get_value(
38
+ self, process_values: Mapping[str, ProcessDataCollection]
39
+ ) -> ProcessData:
40
+ ...
41
+
42
+ @abstractmethod
43
+ def is_available(self) -> bool:
44
+ ...
45
+
46
+
47
+ class _VirtProcessDataItemSum(_VirtProcessDataItemBase):
48
+ def get_value(
49
+ self, process_values: Mapping[str, ProcessDataCollection]
50
+ ) -> ProcessData:
51
+ values: list[float] = []
52
+ for module_id, process_ids in self.available_process_data.items():
53
+ values += (process_values[module_id][pid].value for pid in process_ids)
54
+
55
+ return ProcessData(id=self.processid, unit="W", value=sum(values))
56
+
57
+ def is_available(self) -> bool:
58
+ return len(self.available_process_data) > 0
59
+
60
+
61
+ class _VirtProcessDataItemEnergyToGrid(_VirtProcessDataItemBase):
62
+ def __init__(
63
+ self, processid: str, scope: Literal["Total", "Year", "Month", "Day"]
64
+ ) -> None:
65
+ super().__init__(
66
+ processid,
67
+ {
68
+ "scb:statistic:EnergyFlow": {
69
+ f"Statistic:Yield:{scope}",
70
+ f"Statistic:EnergyHomeBat:{scope}",
71
+ f"Statistic:EnergyHomePv:{scope}",
72
+ }
73
+ },
74
+ )
75
+ self.scope = scope
76
+
77
+ def get_value(
78
+ self, process_values: Mapping[str, ProcessDataCollection]
79
+ ) -> ProcessData:
80
+ statistics = process_values["scb:statistic:EnergyFlow"]
81
+ energy_yield = statistics[f"Statistic:Yield:{self.scope}"].value
82
+ energy_home_bat = statistics[f"Statistic:EnergyHomeBat:{self.scope}"].value
83
+ energy_home_pv = statistics[f"Statistic:EnergyHomePv:{self.scope}"].value
84
+
85
+ return ProcessData(
86
+ id=self.processid,
87
+ unit="Wh",
88
+ value=energy_yield - energy_home_pv - energy_home_bat,
89
+ )
90
+
91
+ def is_available(self) -> bool:
92
+ return self.available_process_data == self.process_data
93
+
94
+
95
+ class _VirtProcessDataManager:
96
+ """Manager for all virtual process data items."""
97
+
98
+ def __init__(self) -> None:
99
+ self._virt_process_data: Iterable[_VirtProcessDataItemBase] = [
100
+ _VirtProcessDataItemSum(
101
+ "pv_P",
102
+ {
103
+ "devices:local:pv1": {"P"},
104
+ "devices:local:pv2": {"P"},
105
+ "devices:local:pv3": {"P"},
106
+ },
107
+ ),
108
+ _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Total", "Total"),
109
+ _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Year", "Year"),
110
+ _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Month", "Month"),
111
+ _VirtProcessDataItemEnergyToGrid("Statistic:EnergyGrid:Day", "Day"),
112
+ ]
113
+
114
+ def initialize(self, available_process_data: Mapping[str, Iterable[str]]):
115
+ """Initialize the virtual items with the list of available process ids."""
116
+ for vpd in self._virt_process_data:
117
+ vpd.update_actual_process_ids(available_process_data)
118
+
119
+ def adapt_process_data_response(
120
+ self, process_data: dict[str, list[str]]
121
+ ) -> Mapping[str, list[str]]:
122
+ """Adapt the reponse of reading process data."""
123
+ virt_process_data: dict[str, list[str]] = {_VIRT_MODUL_ID: []}
124
+
125
+ for vpd in self._virt_process_data:
126
+ if vpd.is_available():
127
+ virt_process_data[_VIRT_MODUL_ID].append(vpd.processid)
128
+
129
+ return ChainMap(process_data, virt_process_data)
130
+
131
+ def adapt_process_value_request(
132
+ self, process_data: Mapping[str, Iterable[str]]
133
+ ) -> Mapping[str, Iterable[str]]:
134
+ """Adapt the request for process values."""
135
+ result: MutableMapping[str, set[str]] = defaultdict(set)
136
+
137
+ for mid, pids in process_data.items():
138
+ result[mid].update(pids)
139
+
140
+ for requested_virtual_process_id in result.pop(_VIRT_MODUL_ID):
141
+ for virtual_process_data in self._virt_process_data:
142
+ if virtual_process_data.is_available():
143
+ if requested_virtual_process_id == virtual_process_data.processid:
144
+ # add ids for virtual if they are missing
145
+ for (
146
+ mid,
147
+ pids,
148
+ ) in virtual_process_data.available_process_data.items():
149
+ result[mid].update(pids)
150
+ break
151
+ else:
152
+ raise ValueError(
153
+ f"No virtual process data '{requested_virtual_process_id}'."
154
+ )
155
+
156
+ return result
157
+
158
+ def adapt_process_value_response(
159
+ self,
160
+ values: Mapping[str, ProcessDataCollection],
161
+ request_data: Mapping[str, Iterable[str]],
162
+ ) -> Mapping[str, ProcessDataCollection]:
163
+ """Adapt the reponse for process values."""
164
+ result = {}
165
+
166
+ # add virtual items
167
+ virtual_process_data_values = []
168
+ for id in request_data[_VIRT_MODUL_ID]:
169
+ for vpd in self._virt_process_data:
170
+ if vpd.processid == id:
171
+ virtual_process_data_values.append(vpd.get_value(values))
172
+ result["_virt_"] = ProcessDataCollection(virtual_process_data_values)
173
+
174
+ # add all values which was requested but not the extra ids for the virtual ids
175
+ for mid, pdc in values.items():
176
+ if mid in request_data:
177
+ pids = [x for x in pdc.values() if x.id in request_data[mid]]
178
+ if len(pids) > 0:
179
+ result[mid] = ProcessDataCollection(pids)
180
+
181
+ return result
182
+
183
+
184
+ class ExtendedApiClient(ApiClient):
185
+ """Extend ApiClient with virtual process data."""
186
+
187
+ def __init__(self, websession: ClientSession, host: str, port: int = 80):
188
+ super().__init__(websession, host, port)
189
+
190
+ self._virt_process_data = _VirtProcessDataManager()
191
+ self._virt_process_data_initialized = False
192
+
193
+ async def get_process_data(self) -> Mapping[str, Iterable[str]]:
194
+ process_data = await super().get_process_data()
195
+
196
+ self._virt_process_data.initialize(process_data)
197
+ self._virt_process_data_initialized = True
198
+ return self._virt_process_data.adapt_process_data_response(process_data)
199
+
200
+ async def get_process_data_values(
201
+ self,
202
+ module_id: Union[str, Mapping[str, Iterable[str]]],
203
+ processdata_id: Union[str, Iterable[str], None] = None,
204
+ ) -> Mapping[str, ProcessDataCollection]:
205
+ contains_virt_process_data = (
206
+ isinstance(module_id, str) and _VIRT_MODUL_ID == module_id
207
+ ) or (isinstance(module_id, dict) and _VIRT_MODUL_ID in module_id)
208
+
209
+ if not contains_virt_process_data:
210
+ # short-cut if no virtual process is requested
211
+ return await super().get_process_data_values(module_id, processdata_id)
212
+
213
+ process_data: dict[str, Iterable[str]] = {}
214
+ if isinstance(module_id, str) and processdata_id is None:
215
+ process_data[module_id] = []
216
+ elif isinstance(module_id, str) and isinstance(processdata_id, str):
217
+ process_data[module_id] = [processdata_id]
218
+ elif (
219
+ isinstance(module_id, str)
220
+ and processdata_id is not None
221
+ and hasattr(processdata_id, "__iter__")
222
+ ):
223
+ process_data[module_id] = list(processdata_id)
224
+ elif isinstance(module_id, Mapping) and processdata_id is None:
225
+ process_data.update(module_id)
226
+ else:
227
+ raise TypeError("Invalid combination of module_id and processdata_id.")
228
+
229
+ if not self._virt_process_data_initialized:
230
+ pd = await self.get_process_data()
231
+ self._virt_process_data.initialize(pd)
232
+ self._virt_process_data_initialized = True
233
+
234
+ process_values = await super().get_process_data_values(
235
+ self._virt_process_data.adapt_process_value_request(process_data)
236
+ )
237
+ return self._virt_process_data.adapt_process_value_response(
238
+ process_values, process_data
239
+ )
pykoplenti/model.py ADDED
@@ -0,0 +1,99 @@
1
+ from datetime import datetime
2
+ from typing import Iterator, Mapping
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class MeData(BaseModel):
8
+ """Represent the data of the 'me'-request."""
9
+
10
+ is_locked: bool = Field(alias="locked")
11
+ is_active: bool = Field(alias="active")
12
+ is_authenticated: bool = Field(alias="authenticated")
13
+ permissions: list[str] = Field()
14
+ is_anonymous: bool = Field(alias="anonymous")
15
+ role: str
16
+
17
+
18
+ class VersionData(BaseModel):
19
+ """Represent the data of the 'version'-request."""
20
+
21
+ api_version: str
22
+ hostname: str
23
+ name: str
24
+ sw_version: str
25
+
26
+
27
+ class ModuleData(BaseModel):
28
+ """Represents a single module."""
29
+
30
+ id: str
31
+ type: str
32
+
33
+
34
+ class ProcessData(BaseModel):
35
+ """Represents a single process data."""
36
+
37
+ id: str
38
+ unit: str
39
+ value: float
40
+
41
+
42
+ class ProcessDataCollection(Mapping):
43
+ """Represents a collection of process data value."""
44
+
45
+ def __init__(self, process_data: list[ProcessData]):
46
+ self._process_data = process_data
47
+
48
+ def __len__(self) -> int:
49
+ return len(self._process_data)
50
+
51
+ def __iter__(self) -> Iterator[str]:
52
+ return (x.id for x in self._process_data)
53
+
54
+ def __getitem__(self, item) -> ProcessData:
55
+ try:
56
+ return next(x for x in self._process_data if x.id == item)
57
+ except StopIteration:
58
+ raise KeyError(item)
59
+
60
+ def __eq__(self, __other: object) -> bool:
61
+ if not isinstance(__other, ProcessDataCollection):
62
+ return False
63
+
64
+ return self._process_data == __other._process_data
65
+
66
+ def __str__(self):
67
+ return "[" + ",".join(str(x) for x in self._process_data) + "]"
68
+
69
+ def __repr__(self):
70
+ return (
71
+ "ProcessDataCollection(["
72
+ + ",".join(repr(x) for x in self._process_data)
73
+ + "])"
74
+ )
75
+
76
+
77
+ class SettingsData(BaseModel):
78
+ """Represents a single settings data."""
79
+
80
+ min: str | None
81
+ max: str | None
82
+ default: str | None
83
+ access: str
84
+ unit: str | None
85
+ id: str
86
+ type: str
87
+
88
+
89
+ class EventData(BaseModel):
90
+ """Represents an event of the inverter."""
91
+
92
+ start_time: datetime
93
+ end_time: datetime
94
+ code: int
95
+ long_description: str
96
+ category: str
97
+ description: str
98
+ group: str
99
+ is_active: bool
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pykoplenti
3
- Version: 1.0.0
3
+ Version: 1.2.1
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
7
- License: UNKNOWN
7
+ Project-URL: repository, https://github.com/stegm/pyclient_koplenti
8
+ Project-URL: changelog, https://github.com/stegm/pykoplenti/blob/master/CHANGELOG.md
9
+ Project-URL: issues, https://github.com/stegm/pykoplenti/issues
8
10
  Keywords: rest kostal plenticore solar
9
- Platform: UNKNOWN
10
11
  Classifier: Development Status :: 4 - Beta
11
12
  Classifier: Environment :: Console
12
13
  Classifier: Intended Audience :: Developers
@@ -16,11 +17,13 @@ Classifier: Programming Language :: Python :: 3.7
16
17
  Classifier: Programming Language :: Python :: 3.8
17
18
  Classifier: Topic :: Software Development :: Libraries
18
19
  Description-Content-Type: text/markdown
19
- Requires-Dist: aiohttp (>=3.6)
20
- Requires-Dist: pycryptodome (>=3.9)
20
+ License-File: LICENSE
21
+ Requires-Dist: aiohttp ~=3.8.5
22
+ Requires-Dist: pycryptodome ~=3.19
23
+ Requires-Dist: pydantic ~=1.10
21
24
  Provides-Extra: cli
22
- Requires-Dist: prompt-toolkit (>=3.0) ; extra == 'cli'
23
- Requires-Dist: click (>=7.1) ; extra == 'cli'
25
+ Requires-Dist: prompt-toolkit >=3.0 ; extra == 'cli'
26
+ Requires-Dist: click >=7.1 ; extra == 'cli'
24
27
 
25
28
  # Python Library for Accessing Kostal Plenticore Inverters
26
29
 
@@ -32,14 +35,15 @@ This library is not affiliated with Kostal and is no offical product. It uses th
32
35
 
33
36
  ## Features
34
37
 
35
- * Authenticate
36
- * Read/Write settings
37
- * Read process data
38
- * Read events
39
- * Download of log data
40
- * Full async-Support for reading and writing data
41
- * Commandline interface for shell access
42
- * Dynamic data model
38
+ - Authenticate
39
+ - Read/Write settings
40
+ - Read process data
41
+ - Read events
42
+ - Download of log data
43
+ - Full async-Support for reading and writing data
44
+ - [Commandline interface](doc/command_line.md) for shell access
45
+ - Dynamic data model - adapts automatically to new process data or settings
46
+ - [Virtual Process Data](doc/virtual_process_data.md) values
43
47
 
44
48
  ## Getting Started
45
49
 
@@ -49,12 +53,11 @@ You will need Python >=3.7.
49
53
 
50
54
  ### Installing the library
51
55
 
52
- Packages of this library are released on [PyPI](https://pypi.org/project/kostal-plenticore/) and can be
53
- installed with `pip`. Alternatively the packages can also be downloaded from
56
+ Packages of this library are released on [PyPI](https://pypi.org/project/kostal-plenticore/) and can be
57
+ installed with `pip`. Alternatively the packages can also be downloaded from
54
58
  [GitHub](https://github.com/stegm/pykoplenti/releases/).
55
59
 
56
-
57
- I recommend to use a [virtual environment](https://docs.python.org/3/library/venv.html) for this,
60
+ I recommend to use a [virtual environment](https://docs.python.org/3/library/venv.html) for this,
58
61
  because it installs the dependecies independently from the system. The installed CLI tools can then be called
59
62
  without activating the virtual environment it.
60
63
 
@@ -68,7 +71,6 @@ $ pip install pykoplenti
68
71
 
69
72
  ### Using the command line interface
70
73
 
71
-
72
74
  Installing the libray with `CLI` provides a new command.
73
75
 
74
76
  ```shell
@@ -133,16 +135,28 @@ home_p = device_local['Home_P']
133
135
 
134
136
  See the full example here: [read_process_data.py](examples/read_process_data.py).
135
137
 
138
+ If you should need installer access use the master key (printed on a label at the side of the inverter)
139
+ and additionally pass your service code:
140
+
141
+ ```python
142
+ await client.login(my_master_key, service_code=my_service_code)
143
+ ```
136
144
 
137
145
  ## Documentation
138
146
 
139
- * [Command Line Interface](doc/command_line.md)
140
- * [Examples](examples/)
147
+ - [Command Line Interface](doc/command_line.md)
148
+ - [Examples](examples/)
149
+ - [Virtual Process Data](doc/virtual_process_data.md)
141
150
 
142
151
  ## Built With
143
152
 
144
- * [AIOHTTPO](https://docs.aiohttp.org/en/stable/) - asyncio for HTTP
145
- * [click](https://click.palletsprojects.com/) - command line interface framework
153
+ - [AIOHTTPO](https://docs.aiohttp.org/en/stable/) - asyncio for HTTP
154
+ - [click](https://click.palletsprojects.com/) - command line interface framework
155
+ - [black](https://github.com/psf/black) - Python code formatter
156
+ - [ruff](https://github.com/astral-sh/ruff) - Python linter
157
+ - [pytest](https://docs.pytest.org/) - Python test framework
158
+ - [mypy](https://mypy-lang.org/) - Python type checker
159
+ - [setuptools](https://github.com/pypa/setuptools) - Python packager
146
160
 
147
161
  ## License
148
162
 
@@ -150,8 +164,4 @@ apache-2.0
150
164
 
151
165
  ## Acknowledgments
152
166
 
153
-
154
-
155
- * [kilianknoll](https://github.com/kilianknoll) for the kostal-RESTAPI project
156
-
157
-
167
+ - [kilianknoll](https://github.com/kilianknoll) for the kostal-RESTAPI project
@@ -0,0 +1,12 @@
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.1.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ pykoplenti-1.2.1.dist-info/METADATA,sha256=ztvYvxRUUOWJYIbUJ4icUf1X14C_55BJMoA6GiN3ABI,5577
9
+ pykoplenti-1.2.1.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
10
+ pykoplenti-1.2.1.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
11
+ pykoplenti-1.2.1.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
12
+ pykoplenti-1.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.36.2)
2
+ Generator: bdist_wheel (0.41.3)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pykoplenti = pykoplenti.cli:cli [CLI]