pymscada 0.2.0__py3-none-any.whl → 0.2.6b9__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.
Files changed (39) hide show
  1. pymscada/__init__.py +8 -2
  2. pymscada/alarms.py +179 -60
  3. pymscada/bus_client.py +12 -2
  4. pymscada/bus_server.py +18 -9
  5. pymscada/callout.py +198 -101
  6. pymscada/config.py +20 -1
  7. pymscada/console.py +19 -6
  8. pymscada/demo/__pycache__/__init__.cpython-311.pyc +0 -0
  9. pymscada/demo/callout.yaml +13 -4
  10. pymscada/demo/files.yaml +3 -2
  11. pymscada/demo/openweather.yaml +3 -11
  12. pymscada/demo/piapi.yaml +15 -0
  13. pymscada/demo/pymscada-io-piapi.service +15 -0
  14. pymscada/demo/pymscada-io-sms.service +18 -0
  15. pymscada/demo/sms.yaml +11 -0
  16. pymscada/demo/tags.yaml +3 -0
  17. pymscada/demo/witsapi.yaml +6 -8
  18. pymscada/demo/wwwserver.yaml +15 -0
  19. pymscada/files.py +1 -0
  20. pymscada/history.py +4 -5
  21. pymscada/iodrivers/logix_map.py +1 -1
  22. pymscada/iodrivers/modbus_client.py +189 -21
  23. pymscada/iodrivers/modbus_map.py +17 -2
  24. pymscada/iodrivers/piapi.py +133 -0
  25. pymscada/iodrivers/sms.py +212 -0
  26. pymscada/iodrivers/witsapi.py +26 -35
  27. pymscada/module_config.py +24 -18
  28. pymscada/opnotes.py +38 -16
  29. pymscada/pdf/__pycache__/__init__.cpython-311.pyc +0 -0
  30. pymscada/tag.py +6 -7
  31. pymscada/tools/get_history.py +147 -0
  32. pymscada/www_server.py +2 -1
  33. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/METADATA +2 -2
  34. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/RECORD +38 -32
  35. pymscada/validate.py +0 -451
  36. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/WHEEL +0 -0
  37. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/entry_points.txt +0 -0
  38. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/licenses/LICENSE +0 -0
  39. {pymscada-0.2.0.dist-info → pymscada-0.2.6b9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,212 @@
1
+ """SMS to RUT241"""
2
+ import asyncio
3
+ from aiohttp import web, ClientSession
4
+ import logging
5
+ import socket
6
+ from typing import Callable
7
+
8
+ from pymscada.bus_client import BusClient
9
+ from pymscada.misc import find_nodes
10
+ from pymscada.periodic import Periodic
11
+ from pymscada.tag import Tag
12
+
13
+
14
+ class RUT241:
15
+ """RUT241 SMS modem."""
16
+ # disable RMS settings
17
+ # just use admin for api call, too damned obscure to configure
18
+ # need to configure SMS to http gateway for incoming SMS
19
+ # point to this server, use aiohttp to serve, awkward but
20
+ # by exception so fast and light
21
+ # set the http forward to use sms_number and sms_message
22
+ # If keeping it simple (i.e. limit to SMS and LAN)
23
+ # Network WAN - turn all off, LAN - static, Wireless - turn off
24
+
25
+
26
+ def __init__(self, ip: str = None, username: str = None,
27
+ password: str = None, port: int = 8080,
28
+ recv_cb: Callable = None, info: dict = {}):
29
+ if ip is None:
30
+ raise ValueError('ip is required')
31
+ if username is None:
32
+ raise ValueError('username is required')
33
+ if password is None:
34
+ raise ValueError('password is required')
35
+
36
+ self.ip = ip
37
+ self.username = username
38
+ self.password = password
39
+ self.port = port
40
+ self.recv_cb = recv_cb
41
+ self.tags = {}
42
+ for info, tagname in info.items():
43
+ self.tags[info] = Tag(tagname, str)
44
+ self.token = None
45
+ self.modem = None
46
+ self.carrier = None
47
+ self.webapp = None
48
+
49
+ async def login(self):
50
+ url = f'https://{self.ip}/api/login'
51
+ json = {'username': self.username,
52
+ 'password': self.password}
53
+ headers = {'Content-Type': 'application/json'}
54
+ async with ClientSession() as session:
55
+ async with session.post(url, json=json, headers=headers,
56
+ ssl=False) as response:
57
+ if response.status == 200:
58
+ resp = await response.json()
59
+ self.token = resp['data']['token']
60
+ logging.info(f'RUT241 login token {self.token}')
61
+ else:
62
+ logging.error(f'RUT241 login error {response.status} '
63
+ f'{response.text}')
64
+
65
+ async def get_modem_info(self):
66
+ if self.token is None:
67
+ await self.login()
68
+ if self.token is None:
69
+ return
70
+ url = f'https://{self.ip}/api/modems/apns/status'
71
+ headers = {'Authorization': f'Bearer {self.token}'}
72
+ async with ClientSession() as session:
73
+ async with session.get(url, headers=headers, ssl=False) as response:
74
+ if response.status == 200:
75
+ resp = await response.json()
76
+ self.modem = next(find_nodes('modem', resp))['modem']
77
+ self.carrier = next(find_nodes('carrier', resp))['carrier']
78
+ logging.info(f'RUT241 {self.modem} on {self.carrier}')
79
+ else:
80
+ self.token = None
81
+ logging.error(f'RUT241 lost token {response.status} '
82
+ f'{response.text}')
83
+
84
+ async def send_sms(self, phone: str, message: str):
85
+ url = f'https://{self.ip}/api/messages/actions/send'
86
+ headers = {'Authorization': f'Bearer {self.token}',
87
+ 'Content-Type': 'application/json'}
88
+ json = {'data': {
89
+ 'number': phone,
90
+ 'message': message,
91
+ 'modem': self.modem
92
+ }}
93
+ logging.info(f'RUT241 {json}')
94
+ async with ClientSession() as session:
95
+ async with session.post(url, headers=headers, json=json, ssl=False) as response:
96
+ if response.status == 200:
97
+ resp = await response.json()
98
+ logging.info(f'RUT241 {resp}')
99
+ else:
100
+ logging.error(f'RUT241 {response.status} {response.text}')
101
+
102
+ async def listen_sms(self):
103
+ webserver = web.Application()
104
+
105
+ async def post_handler(request):
106
+ data = await request.post()
107
+ if data['sms_message'][:2].upper() == 'IN':
108
+ tag = self.tags['__default__']
109
+ if tag.value is None:
110
+ message = '__default__'
111
+ else:
112
+ message = tag.value
113
+ asyncio.create_task(self.send_sms(data['sms_number'], message))
114
+ return web.Response(text='OK', status=200)
115
+ if self.recv_cb is not None:
116
+ self.recv_cb(dict(data))
117
+ return web.Response(text='OK', status=200)
118
+
119
+ webserver.router.add_post('/', post_handler)
120
+ self.webapp = web.AppRunner(webserver)
121
+ await self.webapp.setup()
122
+ site = web.TCPSite(self.webapp, '0.0.0.0', self.port)
123
+ await site.start()
124
+
125
+ async def stop_listening(self):
126
+ if self.webapp is not None:
127
+ await self.webapp.cleanup()
128
+
129
+
130
+ class SMS:
131
+ """Connect to SMS modem."""
132
+
133
+ def __init__(
134
+ self,
135
+ bus_ip: str | None = '127.0.0.1',
136
+ bus_port: int | None = 1324,
137
+ sms_send_tag: str = '__sms_send__',
138
+ sms_recv_tag: str = '__sms_recv__',
139
+ modem: str = 'rut241',
140
+ modem_ip: str | None = None,
141
+ username: str | None = None,
142
+ password: str | None = None,
143
+ listen_port: int | None = 8080,
144
+ info: dict = {}
145
+ ) -> None:
146
+ """
147
+ Connect to SMS, only RUT240 at the moment.
148
+ For testing bus_ip can be None to skip connection.
149
+ ip must be valid and reachable, check.
150
+ """
151
+ if bus_ip is None:
152
+ logging.warning('Callout has bus_ip=None, only use for testing')
153
+ else:
154
+ try:
155
+ socket.gethostbyname(bus_ip)
156
+ except socket.gaierror as e:
157
+ raise ValueError(f'Cannot resolve IP/hostname: {e}')
158
+ if not isinstance(bus_port, int):
159
+ raise TypeError('bus_port must be an integer')
160
+ if not 1024 <= bus_port <= 65535:
161
+ raise ValueError('bus_port must be between 1024 and 65535')
162
+ if not isinstance(sms_send_tag, str) or not sms_send_tag:
163
+ raise ValueError('sms_send_tag must be a non-empty string')
164
+ if not isinstance(sms_recv_tag, str) or not sms_recv_tag:
165
+ raise ValueError('sms_recv_tag must be a non-empty string')
166
+ if modem == 'rut241':
167
+ self.modem = RUT241(ip=modem_ip, username=username,
168
+ password=password, port=listen_port,
169
+ recv_cb=self.sms_recv_cb, info=info)
170
+ else:
171
+ raise ValueError(f'Unknown modem type: {type}')
172
+
173
+ logging.warning(f'SMS {bus_ip} {bus_port} {sms_send_tag} '
174
+ f'{sms_recv_tag}')
175
+ self.sms_send = Tag(sms_send_tag, dict)
176
+ self.sms_send.add_callback(self.sms_send_cb)
177
+ self.sms_recv = Tag(sms_recv_tag, dict)
178
+ self.tags = {}
179
+ for info, tagname in info.items():
180
+ self.tags[info] = Tag(tagname, str)
181
+ self.busclient = None
182
+ if bus_ip is not None:
183
+ self.busclient = BusClient(bus_ip, bus_port, module='SMS')
184
+ self.periodic = Periodic(self.periodic_cb, 60.0)
185
+
186
+ def sms_send_cb(self, tag: Tag):
187
+ """Handle SMS messages from the modem."""
188
+ if tag.value is None:
189
+ return
190
+ number = tag.value['number']
191
+ message = tag.value['message']
192
+ logging.info(f'Sending SMS to {number}: {message}')
193
+ asyncio.create_task(self.modem.send_sms(number, message))
194
+
195
+ def sms_recv_cb(self, value: dict):
196
+ """Handle SMS messages from the modem."""
197
+ self.sms_recv.value = {
198
+ 'number': value['sms_number'],
199
+ 'message': value['sms_message']
200
+ }
201
+
202
+ async def periodic_cb(self):
203
+ """Periodic callback to check alarms and send callouts."""
204
+ await self.modem.get_modem_info()
205
+
206
+ async def start(self):
207
+ """Async startup."""
208
+ if self.busclient is not None:
209
+ await self.busclient.start()
210
+ await self.modem.get_modem_info()
211
+ await self.modem.listen_sms()
212
+ await self.periodic.start()
@@ -17,8 +17,7 @@ class WitsAPIClient:
17
17
  bus_ip: str | None = '127.0.0.1',
18
18
  bus_port: int = 1324,
19
19
  proxy: str | None = None,
20
- api: dict = {},
21
- tags: list = []
20
+ api: dict = {}
22
21
  ) -> None:
23
22
  """
24
23
  Connect to bus on bus_ip:bus_port.
@@ -41,21 +40,21 @@ class WitsAPIClient:
41
40
  raise ValueError("proxy must be a string or None")
42
41
  if not isinstance(api, dict):
43
42
  raise ValueError("api must be a dictionary")
44
- if not isinstance(tags, list):
45
- raise ValueError("tags must be a list")
46
43
 
47
44
  self.busclient = None
48
45
  if bus_ip is not None:
49
46
  self.busclient = BusClient(bus_ip, bus_port, module='WitsAPI')
50
47
  self.proxy = proxy
51
- self.map_bus = id(self)
52
- self.tags = {tagname: Tag(tagname, float) for tagname in tags}
53
-
48
+ self.tags = {}
54
49
  # API configuration
55
50
  self.client_id = api['client_id']
56
51
  self.client_secret = api['client_secret']
57
52
  self.base_url = api['url']
58
53
  self.gxp_list = api.get('gxp_list', [])
54
+ for gxp in self.gxp_list:
55
+ self.tags[gxp] = Tag(gxp, float)
56
+ self.tags[f"{gxp}_Realtime"] = Tag(f"{gxp}_Realtime", float)
57
+ self.tags[f"{gxp}_Forecast"] = Tag(f"{gxp}_Forecast", float)
59
58
  self.back = api.get('back', 2)
60
59
  self.forward = api.get('forward', 72)
61
60
 
@@ -146,34 +145,26 @@ class WitsAPIClient:
146
145
 
147
146
  def update_tags(self, prices):
148
147
  """Update tags with price data"""
149
- for node in prices:
150
- rtd = {}
151
- for trading_time in prices[node]:
152
- if 'RTD' in prices[node][trading_time]:
153
- rtd_price, _ = prices[node][trading_time]['RTD']
154
- rtd[trading_time] = rtd_price
155
- continue
156
- prss_price = None
157
- prsl_price = None
158
- if 'PRSS' in prices[node][trading_time]:
159
- prss_price, prss_last_run = prices[node][trading_time]['PRSS']
160
- if 'PRSL' in prices[node][trading_time]:
161
- prsl_price, prsl_last_run = prices[node][trading_time]['PRSL']
162
- if prsl_price is not None and prss_price is not None:
163
- if prss_last_run > prsl_last_run:
164
- rtd[trading_time] = prss_price
165
- else:
166
- rtd[trading_time] = prsl_price
167
- continue
168
- if prss_price is not None:
169
- rtd[trading_time] = prss_price
170
- elif prsl_price is not None:
171
- rtd[trading_time] = prsl_price
172
- tagname = f"{node}_RTD"
173
- if tagname in self.tags:
174
- for trading_time in sorted(rtd.keys()):
175
- time_us = int(trading_time * 1_000_000)
176
- self.tags[tagname].value = rtd[trading_time], time_us, self.map_bus
148
+ tags = self.tags
149
+ for tagname, data in prices.items():
150
+ realtime = f"{tagname}_Realtime"
151
+ forecast = f"{tagname}_Forecast"
152
+ times = sorted(prices[tagname].keys())
153
+ for time_s in times:
154
+ time_us = int(time_s * 1_000_000)
155
+ if 'RTD' in data[time_s]:
156
+ value, _ = data[time_s]['RTD']
157
+ if True or time_us > tags[realtime].time_us:
158
+ tags[realtime].value = value, time_us
159
+ tags[tagname].value = value, time_us
160
+ if 'PRSS' in data[time_s]:
161
+ value, _ = data[time_s]['PRSS']
162
+ tags[forecast].value = value, time_us
163
+ tags[tagname].value = value, time_us
164
+ elif 'PRSL' in data[time_s]:
165
+ value, _ = data[time_s]['PRSL']
166
+ tags[forecast].value = value, time_us
167
+ tags[tagname].value = value, time_us
177
168
 
178
169
  async def handle_response(self):
179
170
  """Handle responses from the API."""
pymscada/module_config.py CHANGED
@@ -69,19 +69,6 @@ def create_module_registry():
69
69
  module_class='pymscada.callout:Callout',
70
70
  tags=False
71
71
  ),
72
- ModuleDefinition(
73
- name='validate',
74
- help='validate config files',
75
- module_class='pymscada.validate:validate',
76
- config=False,
77
- tags=False,
78
- extra_args=[
79
- ModuleArgument(
80
- ('--path',),
81
- {'metavar': 'file', 'help': 'default is current working directory'}
82
- )
83
- ]
84
- ),
85
72
  ModuleDefinition(
86
73
  name='checkout',
87
74
  help='create example config files',
@@ -139,10 +126,23 @@ def create_module_registry():
139
126
  module_class='pymscada.iodrivers.openweather:OpenWeatherClient',
140
127
  tags=False,
141
128
  epilog=dedent("""
142
- OPENWEATHERMAP_API_KEY can be set in the openweathermap.yaml
129
+ MSCADA_OPENWEATHERMAP_API_KEY can be set in the openweathermap.yaml
130
+ or as an environment variable:
131
+ vi ~/.bashrc
132
+ export MSCADA_OPENWEATHERMAP_API_KEY='1234567890'""")
133
+ ),
134
+ ModuleDefinition(
135
+ name='sms',
136
+ help='send and receive SMS messages through an RUT241 modem',
137
+ module_class='pymscada.iodrivers.sms:SMS',
138
+ tags=False,
139
+ epilog=dedent("""
140
+ MSCADA_SMS_IP, MSCADA_SMS_USERNAME, MSCADA_SMS_PASSWORD can be set in the sms.yaml
143
141
  or as an environment variable:
144
142
  vi ~/.bashrc
145
- export OPENWEATHERMAP_API_KEY='1234567890'""")
143
+ export MSCADA_SMS_IP='192.168.1.2'
144
+ export MSCADA_SMS_USERNAME='smsuser'
145
+ export MSCADA_SMS_PASSWORD='smspass'""")
146
146
  ),
147
147
  ModuleDefinition(
148
148
  name='pingclient',
@@ -162,11 +162,17 @@ def create_module_registry():
162
162
  module_class='pymscada.iodrivers.witsapi:WitsAPIClient',
163
163
  tags=False,
164
164
  epilog=dedent("""
165
- WITS_CLIENT_ID and WITS_CLIENT_SECRET can be set in the wits.yaml
165
+ MSCADA_WITS_CLIENT_ID and MSCADA_WITS_CLIENT_SECRET can be set in the wits.yaml
166
166
  or as environment variables:
167
167
  vi ~/.bashrc
168
- export WITS_CLIENT_ID='your_client_id'
169
- export WITS_CLIENT_SECRET='your_client_secret'""")
168
+ export MSCADA_WITS_CLIENT_ID='your_client_id'
169
+ export MSCADA_WITS_CLIENT_SECRET='your_client_secret'""")
170
+ ),
171
+ ModuleDefinition(
172
+ name='piapi',
173
+ help='poll WITS GXP pricing real time dispatch and forecast',
174
+ module_class='pymscada.iodrivers.piapi:PIWebAPIClient',
175
+ tags=False
170
176
  ),
171
177
  ModuleDefinition(
172
178
  name='console',
pymscada/opnotes.py CHANGED
@@ -2,6 +2,7 @@
2
2
  import logging
3
3
  import sqlite3 # note that sqlite3 has blocking calls
4
4
  import socket
5
+ from datetime import datetime
5
6
  from pymscada.bus_client import BusClient
6
7
  from pymscada.tag import Tag
7
8
 
@@ -11,7 +12,7 @@ class OpNotes:
11
12
 
12
13
  def __init__(
13
14
  self,
14
- bus_ip: str = '127.0.0.1',
15
+ bus_ip: str|None = '127.0.0.1',
15
16
  bus_port: int = 1324,
16
17
  db: str | None = None,
17
18
  table: str = 'opnotes',
@@ -52,7 +53,7 @@ class OpNotes:
52
53
  self._init_table()
53
54
  self.busclient = BusClient(bus_ip, bus_port, module='OpNotes')
54
55
  self.rta = Tag(rta_tag, dict)
55
- self.rta.value = {}
56
+ self.rta.value = {'__rta_id__': 0}
56
57
  self.busclient.add_callback_rta(rta_tag, self.rta_cb)
57
58
 
58
59
  def _init_table(self):
@@ -101,12 +102,21 @@ class OpNotes:
101
102
 
102
103
  def rta_cb(self, request):
103
104
  """Respond to Request to Author and publish on rta_tag as needed."""
105
+ local_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
106
+ logging.info(f'[{local_time}] RTA callback received: {request}')
104
107
  if 'action' not in request:
105
108
  logging.warning(f'rta_cb malformed {request}')
106
109
  elif request['action'] == 'ADD':
107
110
  try:
108
- logging.info(f'add {request}')
109
111
  with self.connection:
112
+ if not isinstance(request, dict) or \
113
+ 'date_ms' not in request or \
114
+ 'site' not in request or \
115
+ 'by' not in request or \
116
+ 'note' not in request or \
117
+ 'abnormal' not in request:
118
+ logging.warning(f'rta_cb ADD malformed {request}')
119
+ return
110
120
  self.cursor.execute(
111
121
  f'INSERT INTO {self.table} '
112
122
  '(date_ms, site, by, note, abnormal) '
@@ -114,7 +124,8 @@ class OpNotes:
114
124
  'RETURNING *;',
115
125
  request)
116
126
  res = self.cursor.fetchone()
117
- self.rta.value = {
127
+ response = {
128
+ '__rta_id__': 0,
118
129
  'id': res[0],
119
130
  'date_ms': res[1],
120
131
  'site': res[2],
@@ -122,17 +133,27 @@ class OpNotes:
122
133
  'note': res[4],
123
134
  'abnormal': res[5]
124
135
  }
125
- except sqlite3.IntegrityError as error:
136
+ self.rta.value = response
137
+ except sqlite3.Error as error:
126
138
  logging.warning(f'OpNotes rta_cb {error}')
127
139
  elif request['action'] == 'MODIFY':
128
140
  try:
129
- logging.info(f'modify {request}')
130
141
  with self.connection:
142
+ if not isinstance(request, dict) or \
143
+ 'id' not in request or \
144
+ 'date_ms' not in request or \
145
+ 'site' not in request or \
146
+ 'by' not in request or \
147
+ 'note' not in request or \
148
+ 'abnormal' not in request:
149
+ logging.warning(f'rta_cb MODIFY malformed {request}')
150
+ return
131
151
  self.cursor.execute(
132
152
  f'REPLACE INTO {self.table} VALUES(:id, :date_ms, '
133
153
  ':site, :by, :note, :abnormal) RETURNING *;', request)
134
154
  res = self.cursor.fetchone()
135
155
  self.rta.value = {
156
+ '__rta_id__': 0,
136
157
  'id': res[0],
137
158
  'date_ms': res[1],
138
159
  'site': res[2],
@@ -140,20 +161,22 @@ class OpNotes:
140
161
  'note': res[4],
141
162
  'abnormal': res[5]
142
163
  }
143
- except sqlite3.IntegrityError as error:
164
+ except sqlite3.Error as error:
144
165
  logging.warning(f'OpNotes rta_cb {error}')
145
166
  elif request['action'] == 'DELETE':
146
167
  try:
147
- logging.info(f'delete {request}')
148
168
  with self.connection:
169
+ if not isinstance(request, dict) or \
170
+ 'id' not in request:
171
+ logging.warning(f'rta_cb DELETE malformed {request}')
172
+ return
149
173
  self.cursor.execute(
150
174
  f'DELETE FROM {self.table} WHERE id = :id;', request)
151
- self.rta.value = {'id': request['id']}
152
- except sqlite3.IntegrityError as error:
175
+ self.rta.value = {'__rta_id__': 0, 'id': request['id']}
176
+ except sqlite3.Error as error:
153
177
  logging.warning(f'OpNotes rta_cb {error}')
154
178
  elif request['action'] == 'HISTORY':
155
179
  try:
156
- logging.info(f'history {request}')
157
180
  with self.connection:
158
181
  self.cursor.execute(
159
182
  f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
@@ -168,19 +191,18 @@ class OpNotes:
168
191
  'note': res[4],
169
192
  'abnormal': res[5]
170
193
  }
171
- except sqlite3.IntegrityError as error:
194
+ except sqlite3.Error as error:
172
195
  logging.warning(f'OpNotes rta_cb {error}')
173
196
  elif request['action'] == 'BULK HISTORY':
174
197
  try:
175
- logging.info(f'bulk history {request}')
176
198
  with self.connection:
177
199
  self.cursor.execute(
178
200
  f'SELECT * FROM {self.table} WHERE date_ms > :date_ms '
179
201
  'ORDER BY -date_ms;', request)
180
202
  results = list(self.cursor.fetchall())
181
- self.rta.value = {'__rta_id__': request['__rta_id__'],
182
- 'data': results}
183
- except sqlite3.IntegrityError as error:
203
+ response = {'__rta_id__': request['__rta_id__'], 'data': results}
204
+ self.rta.value = response
205
+ except sqlite3.Error as error:
184
206
  logging.warning(f'OpNotes rta_cb {error}')
185
207
 
186
208
  async def start(self):
pymscada/tag.py CHANGED
@@ -21,10 +21,10 @@ TYPES = {
21
21
  class UniqueTag(type):
22
22
  """Super Tag class only create unique tags for unique tag names."""
23
23
 
24
- __cache = {}
24
+ __cache: dict[str, 'Tag'] = {}
25
25
  __notify = None
26
26
 
27
- def __call__(cls, tagname: str, tagtype: type = None):
27
+ def __call__(cls, tagname: str, tagtype: type | None = None):
28
28
  """Each time a new tag is created, check if it is really new."""
29
29
  if tagname in cls.__cache:
30
30
  tag = cls.__cache[tagname]
@@ -33,8 +33,7 @@ class UniqueTag(type):
33
33
  else:
34
34
  if tagtype is None:
35
35
  raise TypeError(f"{tagname} type is undefined.")
36
- tag = cls.__new__(cls, tagname, tagtype)
37
- tag.__init__(tagname, tagtype)
36
+ tag: Tag = super().__call__(tagname, tagtype)
38
37
  tag.id = None
39
38
  cls.__cache[tagname] = tag
40
39
  if cls.__notify is not None:
@@ -43,11 +42,11 @@ class UniqueTag(type):
43
42
 
44
43
  def tagnames(cls) -> list[str]:
45
44
  """Return all tagnames of class Tag."""
46
- return cls.__cache.keys()
45
+ return cls.__cache.keys() # type: ignore
47
46
 
48
- def tags(cls) -> list:
47
+ def tags(cls) -> list['Tag']:
49
48
  """Return all tags of class Tag."""
50
- return cls.__cache.values()
49
+ return cls.__cache.values() # type: ignore
51
50
 
52
51
  def set_notify(cls, callback):
53
52
  """Set ONE routine to notify when new tags are added."""