Skip to content

Commit 5e857ce

Browse files
committed
jsonread plugin: make it work without pyjq under python 3.13 (limited functionality)
1 parent 0934e6a commit 5e857ce

File tree

4 files changed

+187
-99
lines changed

4 files changed

+187
-99
lines changed

jsonread/__init__.py

Lines changed: 164 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -27,119 +27,228 @@
2727
import json
2828
import requests
2929
from requests_file import FileAdapter
30-
import pyjq
30+
import re
3131
from lib.model.smartplugin import SmartPlugin
3232
from lib.item import Items
3333
from .webif import WebInterface
3434

35+
def jq_compile(expr):
36+
"""Split filter expression into pipe steps"""
37+
return tuple(p.strip() for p in expr.strip().split("|"))
38+
39+
def jq_full(pipes, value):
40+
"""Apply pipe chain like jq"""
41+
for pipe in pipes:
42+
pipe = pipe.strip()
43+
if not isinstance(value, list):
44+
value = [value]
45+
out = []
46+
for v in value:
47+
res = jq_step(pipe, v)
48+
if isinstance(res, list):
49+
out.extend(res)
50+
elif res is not None:
51+
out.append(res)
52+
value = out
53+
return value
54+
55+
def jq_step(expr, value):
56+
expr = expr.strip()
57+
58+
# select() über Listen
59+
if expr.startswith("select(") and expr.endswith(")"):
60+
cond = expr[7:-1]
61+
if isinstance(value, list):
62+
return [v for v in value if jq_condition(cond, v)]
63+
return [value] if jq_condition(cond, value) else []
64+
65+
# [] Operator
66+
if expr.endswith("[]"):
67+
base = expr[:-2]
68+
res = jq_path(base, value)
69+
if isinstance(res, list):
70+
return res
71+
return []
72+
73+
# normal path
74+
return jq_path(expr, value)
75+
76+
def jq_condition(cond, obj):
77+
m = re.match(r'\.(.+?)\s*==\s*("?)(.*?)\2$', cond)
78+
if m:
79+
key, _, val = m.groups()
80+
if isinstance(obj, dict):
81+
return str(obj.get(key)) == val
82+
return False
83+
84+
def jq_path(path, data):
85+
"""Resolve a dot-separated path, handling [] and quoted keys"""
86+
path = path.lstrip(".")
87+
if path == "":
88+
return data
89+
90+
# Split respecting quoted keys
91+
parts = []
92+
buf = ""
93+
in_quotes = False
94+
for ch in path:
95+
if ch == '"':
96+
in_quotes = not in_quotes
97+
buf += ch
98+
elif ch == "." and not in_quotes:
99+
parts.append(buf)
100+
buf = ""
101+
else:
102+
buf += ch
103+
if buf:
104+
parts.append(buf)
105+
106+
def normalize_key(k):
107+
k = k.strip()
108+
if k.startswith('"') and k.endswith('"'):
109+
return k[1:-1]
110+
return k
111+
112+
vals = [data]
113+
for part in parts:
114+
key = normalize_key(part)
115+
is_list = False
116+
if key.endswith("[]"):
117+
key = key[:-2]
118+
is_list = True
119+
new_vals = []
120+
for v in vals:
121+
if isinstance(v, dict) and key in v:
122+
val = v[key]
123+
if is_list:
124+
if isinstance(val, list):
125+
new_vals.extend(val)
126+
else:
127+
new_vals.append(val)
128+
else:
129+
new_vals.append(val)
130+
elif isinstance(v, list):
131+
for e in v:
132+
if isinstance(e, dict) and key in e:
133+
val = e[key]
134+
if is_list and isinstance(val, list):
135+
new_vals.extend(val)
136+
else:
137+
new_vals.append(val)
138+
vals = new_vals
139+
# pyjq.first() compatibility
140+
if len(vals) == 0:
141+
return None
142+
if len(vals) == 1:
143+
return vals[0]
144+
return vals
145+
146+
def jq_unwrap(value):
147+
"""pyjq.first()-compatibility"""
148+
if isinstance(value, list):
149+
if len(value) == 0:
150+
return None
151+
if len(value) == 1:
152+
return value[0]
153+
return value
154+
155+
# ============================================================
156+
# JSONREAD Plugin (Turbo + jq kompatibel)
157+
# ============================================================
35158

36159
class JSONREAD(SmartPlugin):
37-
PLUGIN_VERSION = "1.0.4"
160+
PLUGIN_VERSION = "2.0.0"
38161

39162
def __init__(self, sh):
40-
"""
41-
Initializes the plugin
42-
@param url: URL of the json data to fetch
43-
@param cycle: the polling interval in seconds
44-
"""
45-
# Call init code of parent class (SmartPlugin)
46163
super().__init__()
47164

48-
from bin.smarthome import VERSION
49-
if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5':
50-
self.logger = logging.getLogger(__name__)
51-
52165
self._url = self.get_parameter_value('url')
53166
self._cycle = self.get_parameter_value('cycle')
167+
54168
self._session = requests.Session()
55169
self._session.mount('file://', FileAdapter())
170+
56171
self._items = {}
172+
self._compiled_filters = {}
173+
57174
self._lastresult = {}
58175
self._lastresultstr = ""
59176
self._lastresultjq = ""
60177

61-
# if plugin should start even without web interface
62178
self.init_webinterface(WebInterface)
63179

64-
65180
def run(self):
66-
"""
67-
Run method for the plugin
68-
"""
69181
self.logger.debug("Run method called")
70182
self.alive = True
71183
self.scheduler_add(self.get_fullname(), self.poll_device, cycle=self._cycle)
72184

73185
def stop(self):
74186
self.logger.debug("Stop method called")
75-
self.scheduler_remove(self.get_fullname() )
187+
self.scheduler_remove(self.get_fullname())
76188
self.alive = False
77189

78190
def parse_item(self, item):
79191
if self.has_iattr(item.conf, 'jsonread_filter'):
80-
self._items[item] = self.get_iattr_value(item.conf, 'jsonread_filter')
192+
expr = self.get_iattr_value(item.conf, 'jsonread_filter')
193+
self._items[item] = expr
194+
self._compiled_filters[item] = jq_compile(expr)
81195

82196
def poll_device(self):
83197
try:
84198
response = self._session.get(self._url)
85-
86199
except Exception as ex:
87-
self.logger.error("Exception when sending GET request for {}: {}".format(self._url,str(ex)))
200+
self.logger.error(f"GET failed {self._url}: {ex}")
88201
return
89202

90203
if response.status_code != 200:
91-
self.logger.error("Bad response code from GET '{}': {}".format(self._url, response.status_code))
204+
self.logger.error(f"Bad HTTP {response.status_code} from {self._url}")
92205
return
93206

94207
try:
95208
json_obj = response.json()
96-
except Exception as ex:
97-
self.logger.error("Response from '{}' doesn't look like json '{}'".format(self._url, str(response.content)[:30]))
209+
except Exception:
210+
self.logger.error(f"Response from {self._url} is not JSON")
98211
return
99212

213+
# Store debug info (Unicode-safe)
100214
try:
101215
self._lastresult = json_obj
102-
self._lastresultstr = json.dumps(self._lastresult, indent=4, sort_keys=True)
103-
self._lastresultjq = '\n'.join(str(x) for x in pathes(self._lastresult))
104-
except Exception as ex:
105-
self.logger.error("Could not change '{}' into pretty json string'{}'".format(self._lastresult,self._lastresultstr))
106-
self._lastresultstr = "<empty due to failure>"
216+
self._lastresultstr = json.dumps(json_obj, indent=4, sort_keys=True, ensure_ascii=False)
217+
self._lastresultjq = '\n'.join(pathes(json_obj))
218+
except Exception:
219+
self._lastresultstr = "<format error>"
107220

108-
for k in self._items.keys():
221+
# Process items (Turbo mode)
222+
for item, expr in self._items.items():
109223
try:
110-
jqres = pyjq.first(self._items[k], json_obj)
111-
224+
compiled = self._compiled_filters[item]
225+
jqres = jq_full(compiled, json_obj)
226+
jqres = jq_unwrap(jqres)
112227
except Exception as ex:
113-
self.logger.error("jq filter failed: {}'".format(str(ex)))
228+
self.logger.error(f"jq failed: {expr} => {ex}")
114229
continue
115230

116-
k(jqres)
231+
try:
232+
item(jqres)
233+
except Exception as ex:
234+
self.logger.error(f"Item update failed {item}: {ex}")
117235

118-
# just a helper function
236+
# ============================================================
237+
# Debug helper
238+
# ============================================================
119239

120-
def pathes( d, stem=""):
121-
#print("Stem:",stem)
240+
def pathes(d, stem=""):
122241
if isinstance(d, dict):
123242
for key, value in d.items():
124-
if isinstance(value, dict):
125-
for d in pathes(value, "{}.{}".format(stem,key)):
126-
yield d
127-
elif isinstance(value, list) or isinstance(value, tuple):
128-
for v in value:
129-
for d in pathes(v, "{}.{}".format(stem,key)):
130-
yield d
243+
if isinstance(value, (dict, list, tuple)):
244+
yield from pathes(value, f"{stem}.{key}")
131245
else:
132-
yield "{}.{} => {}".format(stem,key,value)
133-
elif isinstance(d, list) or isinstance(d, tuple):
246+
yield f"{stem}.{key} => {value}"
247+
elif isinstance(d, (list, tuple)):
134248
for value in d:
135-
if isinstance(value, dict):
136-
for d in pathes(value, "{}.{}".format(stem,key)):
137-
yield d
138-
elif isinstance(value, list) or isinstance(value, tuple):
139-
for v in value:
140-
for d in pathes(v, "{}.{}".format(stem,key)):
141-
yield d
249+
if isinstance(value, (dict, list, tuple)):
250+
yield from pathes(value, stem)
142251
else:
143-
yield "{}.{} => {}".format(stem,key,value)
252+
yield f"{stem} => {value}"
144253
else:
145-
yield "{}.{}".format(stem,d)
254+
yield f"{stem}.{d}"

jsonread/plugin.yaml

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,19 @@ plugin:
33
# Global plugin attributes
44
type: web # plugin type (gateway, interface, protocol, system, web)
55
description:
6-
de: 'json Parser Plugin basierend auf jq'
7-
en: 'json parser plugin based on jq'
6+
de: 'json Parser Plugin ohne weitere Module'
7+
en: 'json parser plugin ohne weitere Module'
88
maintainer: onkelandy
99
tester: bmxp
1010
state: ready
11-
keywords: json jq
12-
documentation: http://smarthomeng.de/user/plugins_doc/config/not-yet.html
11+
keywords: json
12+
documentation: http://smarthomeng.de/user/plugins_doc/config/jsonread.html
1313
support: https://knx-user-forum.de/forum/supportforen/smarthome-py/not-yet
1414

15-
version: 1.0.4 # Plugin version
15+
version: 2.0.0 # Plugin version
16+
17+
py_minversion: 3.9 # minimum Python version to use for this plugin
1618
sh_minversion: '1.4' # minimum shNG version to use this plugin
17-
#sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest)
18-
#py_minversion: 3.6 # minimum Python version to use for this plugin
19-
py_maxversion: '3.11' # maximum Python version to use for this plugin (leave empty if latest)
20-
py_versioncomment: "Die aktuellste Version (2.6.0, released August 2022) des benötigten Packages 'pyjq' ist nicht kompatibel mit Python 3.12"
2119
restartable: True
2220
multi_instance: True # plugin supports multi instance
2321
classname: JSONREAD # class containing the plugin
@@ -42,22 +40,14 @@ parameters:
4240

4341

4442
item_attributes:
45-
# Definition of item attributes defined by this plugin
4643
jsonread_filter:
4744
type: str
4845
description:
4946
de: 'JQ Pfad um innerhalb eines JSON Datensatzes einen Wert auszuwählen. Dieser Wert wird dem Item dann zugewiesen'
5047
en: 'JQ path to select a value from JSON dataset. The Item will then receive this value'
5148

5249
item_structs: NONE
53-
# Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty)
54-
55-
#item_attribute_prefixes:
56-
# Definition of item attributes that only have a common prefix (enter 'item_attribute_prefixes: NONE' or ommit this section, if section should be empty)
57-
# NOTE: This section should only be used, if really nessesary (e.g. for the stateengine plugin)
5850

5951
logic_parameters: NONE
60-
# Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty)
6152

6253
plugin_functions: NONE
63-
# Definition of function interface of the plugin

jsonread/requirements.txt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1 @@
1-
#requests requirement moved to core
21
requests-file
3-
# pyjq:
4-
# The most recent version (2.6 released August 2022) is compatible with Python 3.10
5-
pyjq
6-
# The version (2.5.2 released May 2021) is not compatible with Python 3.10
7-
# The project's README states, that the project is deprecated in favor Slixmpp (a fork of sleekxmpp).
8-
#pyjq;python_version<'3.10'

0 commit comments

Comments
 (0)