dron 0.1.20241008__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.
dron/launchd.py ADDED
@@ -0,0 +1,415 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import sys
9
+ import textwrap
10
+ from datetime import timedelta
11
+ from pathlib import Path
12
+ from subprocess import PIPE, Popen, check_call, check_output
13
+ from tempfile import TemporaryDirectory
14
+ from typing import Any, Iterator, Sequence
15
+
16
+ from .api import (
17
+ OnCalendar,
18
+ OnFailureAction,
19
+ When,
20
+ )
21
+ from .common import (
22
+ ALWAYS,
23
+ MANAGED_MARKER,
24
+ Command,
25
+ LaunchdUnitState,
26
+ MonitorEntry,
27
+ MonitorParams,
28
+ State,
29
+ Unit,
30
+ UnitFile,
31
+ logger,
32
+ unwrap,
33
+ )
34
+
35
+ # TODO custom launchd domain?? maybe instead could do dron/ or something?
36
+ _LAUNCHD_DOMAIN = f'gui/{os.getuid()}'
37
+
38
+
39
+ # in principle not necessary...
40
+ # but makes it much easier to filter out logs & lobs from launchctl dump
41
+ DRON_PREFIX = 'dron.'
42
+
43
+
44
+ def _launchctl(*args: Path | str) -> list[Path | str]:
45
+ return ['launchctl', *args]
46
+
47
+
48
+ def _launch_agent(path: str) -> Path:
49
+ # symlink for autostart
50
+ assert path.endswith('.plist'), path # meh
51
+ assert not Path(path).is_absolute(), path
52
+
53
+ LA = Path('~/Library/LaunchAgents').expanduser()
54
+ link = LA / path
55
+ return link
56
+
57
+
58
+ def fqn(name: Unit) -> str:
59
+ return _LAUNCHD_DOMAIN + '/' + DRON_PREFIX + name
60
+
61
+
62
+ def launchctl_load(*, unit_file: UnitFile) -> None:
63
+ # bootstrap is nicer than load
64
+ # load is super defensive, returns code 0 on errors
65
+ check_call(_launchctl('bootstrap', _LAUNCHD_DOMAIN, unit_file))
66
+ _launch_agent(unit_file.name).symlink_to(unit_file)
67
+
68
+
69
+ def launchctl_unload(*, unit: Unit) -> None:
70
+ # bootout is more verbose than unload
71
+ # in addition unload is super defensive, returns code 0 on errors
72
+ check_call(_launchctl('bootout', fqn(unit)))
73
+ _launch_agent(unit + '.plist').unlink()
74
+
75
+
76
+ def launchctl_kickstart(*, unit: Unit) -> None:
77
+ check_call(_launchctl('kickstart', fqn(unit)))
78
+
79
+
80
+ def launchctl_reload(*, unit: Unit, unit_file: UnitFile) -> None:
81
+ # don't think there is a better way?
82
+ launchctl_unload(unit=unit)
83
+ launchctl_load(unit_file=unit_file)
84
+
85
+
86
+ def launchd_wrapper(*, job: str, on_failure: list[str]) -> list[str]:
87
+ # fmt: off
88
+ return [
89
+ sys.executable,
90
+ '-m',
91
+ 'dron.launchd_wrapper',
92
+ *itertools.chain.from_iterable(('--notify', n) for n in on_failure),
93
+ '--job', job,
94
+ '--',
95
+ ]
96
+ # fmt: on
97
+
98
+
99
+ def remove_launchd_wrapper(cmd: str) -> str:
100
+ if ' dron.launchd_wrapper ' not in cmd:
101
+ return cmd
102
+ # uhh... not super reliable, but this is only used for monitor so hopefully fine
103
+ [_, cmd] = cmd.split(' -- ', maxsplit=1)
104
+ return cmd
105
+
106
+
107
+ def plist(
108
+ *,
109
+ unit_name: str,
110
+ command: Command,
111
+ on_failure: Sequence[OnFailureAction],
112
+ when: When | None=None,
113
+ ) -> str:
114
+ # TODO hmm, kinda mirrors 'escape' method, not sure
115
+ cmd: Sequence[str]
116
+ if isinstance(command, (list, tuple)):
117
+ cmd = tuple(map(str, command))
118
+ elif isinstance(command, Path):
119
+ cmd = [str(command)]
120
+ elif isinstance(command, str) and ' ' not in command:
121
+ cmd = [command]
122
+ else:
123
+ # unquoting and splitting is way trickier than quoting and joining...
124
+ # not sure how to implement it p
125
+ # maybe we just want bash -c in this case, dunno how to implement properly
126
+ raise RuntimeError(command)
127
+ del command
128
+
129
+ mschedule = ''
130
+ if when is None:
131
+ # support later
132
+ raise RuntimeError(unit_name)
133
+
134
+ if when == ALWAYS:
135
+ mschedule = '<key>KeepAlive</key>\n<true/>'
136
+ else:
137
+ assert isinstance(when, OnCalendar), when
138
+ # https://www.freedesktop.org/software/systemd/man/systemd.time.html#
139
+ seconds = {
140
+ 'minutely': 60,
141
+ 'hourly' : 60 * 60,
142
+ 'daily' : 60 * 60 * 24,
143
+ }.get(when)
144
+ if seconds is None:
145
+ # ok, try systemd-like spec..
146
+ specs = [
147
+ (re.escape('*:0/') + r'(\d+)', 60),
148
+ (re.escape('*:*:0/') + r'(\d+)', 1 ),
149
+ ]
150
+ for rgx, mult in specs:
151
+ m = re.fullmatch(rgx, when)
152
+ if m is not None:
153
+ num = m.group(1)
154
+ seconds = int(num) * mult
155
+ break
156
+ if seconds is None:
157
+ # try to parse as hh:mm at least
158
+ m = re.fullmatch(r'(\d\d):(\d\d)', when)
159
+ assert m is not None, when
160
+ hh = m.group(1)
161
+ mm = m.group(2)
162
+ mschedule = '\n'.join([
163
+ '<key>StartCalendarInterval</key>',
164
+ '<dict>',
165
+ '<key>Hour</key>' , f'<integer>{int(hh)}</integer>',
166
+ '<key>Minute</key>', f'<integer>{int(mm)}</integer>',
167
+ '</dict>',
168
+ ])
169
+ else:
170
+ mschedule = '\n'.join(('<key>StartInterval</key>', f'<integer>{seconds}</integer>'))
171
+
172
+ assert mschedule != '', unit_name
173
+
174
+ # meh.. not sure how to reconcile it better with systemd
175
+ on_failure = [
176
+ x.replace('--job %n', f'--job {unit_name}') + ' --stdin'
177
+ for x in on_failure
178
+ ]
179
+
180
+ # attempt to set argv[0] properly
181
+ # hmm I was hoping it would make desktop notifications ('background service added' nicer)
182
+ # but even after that it still only shows executable script name. ugh
183
+ # program_argv = (unit_name, *cmd[1:])
184
+ program_argv = (
185
+ *launchd_wrapper(job=unit_name, on_failure=on_failure),
186
+ *cmd,
187
+ )
188
+ del cmd
189
+ program_argvs = '\n'.join(f'<string>{c}</string>' for c in program_argv)
190
+
191
+ # TODO add log file, although mailer is already capturing stdout
192
+ # TODO hmm maybe use the same log file for all dron jobs? would make it easier to rotate?
193
+ res = f'''
194
+ <?xml version="1.0" encoding="UTF-8"?>
195
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
196
+ <plist version="1.0">
197
+ <dict>
198
+
199
+ <key>Label</key>
200
+ <string>{DRON_PREFIX}{unit_name}</string>
201
+ <key>ProgramArguments</key>
202
+ <array>
203
+ {textwrap.indent(program_argvs, " " * 8)}
204
+ </array>
205
+
206
+ <key>RunAtLoad</key>
207
+ <true/>
208
+
209
+ {textwrap.indent(mschedule, " " * 8)}
210
+
211
+ <key>Comment</key>
212
+ <string>{MANAGED_MARKER}</string>
213
+ </dict>
214
+ </plist>
215
+ '''.lstrip()
216
+ return res
217
+
218
+
219
+ from .common import LaunchdUnitState
220
+
221
+
222
+ def launchd_state(*, with_body: bool) -> Iterator[LaunchdUnitState]:
223
+ # sadly doesn't look like it has json interface??
224
+ dump = check_output(['launchctl', 'dumpstate']).decode('utf8')
225
+
226
+ name: str | None = None
227
+ extras: dict[str, Any] = {}
228
+ arguments: list[str] | None = None
229
+ all_props: str | None = None
230
+ fields = [
231
+ 'path',
232
+ 'last exit code',
233
+ 'pid',
234
+ 'run interval',
235
+ ]
236
+ for line in dump.splitlines():
237
+ if name is None:
238
+ # start of job description group
239
+ name = line.removesuffix(' = {')
240
+ all_props = ''
241
+ continue
242
+ elif line == '}':
243
+ # end of job description group
244
+ path: str | None = extras.get('path')
245
+ if path is not None and 'dron' in path:
246
+ # otherwsie likely some sort of system unit
247
+ unit_file = Path(path)
248
+ body = unit_file.read_text() if with_body else None
249
+
250
+ # TODO extract 'state'??
251
+
252
+ periodic_schedule = extras.get('run interval')
253
+ calendal_schedule = 'com.apple.launchd.calendarinterval' in unwrap(all_props)
254
+
255
+ schedule: str | None = None
256
+ if periodic_schedule is not None:
257
+ schedule = 'every ' + periodic_schedule
258
+ elif calendal_schedule:
259
+ # TODO parse properly
260
+ schedule = 'calendar'
261
+ else:
262
+ # NOTE: seems like keepalive attribute isn't present in launcd dumpstate output
263
+ schedule = 'always'
264
+
265
+ yield LaunchdUnitState(
266
+ unit_file=Path(path),
267
+ body=body,
268
+ cmdline=tuple(extras['arguments']),
269
+ # might not be present when we killed process manually?
270
+ last_exit_code=extras.get('last exit code'),
271
+ # pid might not be present (presumably when it's not running)
272
+ pid=extras.get('pid'),
273
+ schedule=schedule,
274
+ )
275
+ name = None
276
+ all_props = None
277
+ extras = {}
278
+ continue
279
+
280
+ all_props = unwrap(all_props) + line + '\n'
281
+
282
+ if arguments is not None:
283
+ if line == '\t}':
284
+ extras['arguments'] = arguments
285
+ arguments = None
286
+ else:
287
+ arg = line.removeprefix('\t\t')
288
+ arguments.append(arg)
289
+ else:
290
+ xx = line.removeprefix('\t')
291
+ for f in fields:
292
+ zz = f'{f} = '
293
+ if xx.startswith(zz):
294
+ extras[f] = xx.removeprefix(zz)
295
+ break
296
+ # special handling..
297
+ if xx.startswith('arguments = '):
298
+ arguments = []
299
+
300
+
301
+ def verify_unit(*, unit_name: str, body: str) -> None:
302
+ with TemporaryDirectory() as tdir:
303
+ tfile = Path(tdir) / unit_name
304
+ tfile.write_text(body)
305
+ check_call([
306
+ 'plutil', '-lint',
307
+ '-s', # silent on success
308
+ tfile,
309
+ ])
310
+
311
+
312
+ def cmd_past(unit: Unit) -> None:
313
+ sub = fqn('dron.' + unit)
314
+ cmd = [
315
+ # todo maybe use 'stream'??
316
+ 'log', 'show', '--info',
317
+ # '--last', '24h',
318
+ # hmm vvv that doesn't work, if we pass pid, predicate is ignored?
319
+ # '--process', '1',
320
+ # hmm, oddly enough "&&" massively slows the predicate??
321
+ #'--predicate', f'processIdentifier=1 && (subsystem contains "gui/501/dron.{unit}")',
322
+ '--predicate', f'subsystem contains "{sub}"',
323
+ '--style', 'ndjson',
324
+ '--color', 'always',
325
+ ]
326
+ with Popen(cmd, stdout=PIPE, encoding='utf8') as p:
327
+ out = p.stdout; assert out is not None
328
+ for line in out:
329
+ j = json.loads(line)
330
+ if j.get('finished') == 1:
331
+ # last event at the very end
332
+ continue
333
+ subsystem = j['subsystem']
334
+ # sometimes subsystem contains pid at the end, need to chop it off
335
+ # also that's wjy we can't use "subsystem = " predicate :(
336
+ subsystem = subsystem.split(' ')[0]
337
+ if sub != subsystem:
338
+ continue
339
+ msg = j['eventMessage']
340
+
341
+ interesting = re.search(' spawned .* because', msg) or 'exited ' in msg
342
+ if not interesting:
343
+ continue
344
+ ts = j['timestamp']
345
+ print(ts, sub, msg)
346
+
347
+
348
+ def cmd_run(*, unit: Unit, do_exec: bool) -> None:
349
+ if not do_exec:
350
+ launchctl_kickstart(unit=unit)
351
+ return
352
+
353
+ states = []
354
+ for s in launchd_state(with_body=False):
355
+ if s.unit_file.stem == unit:
356
+ states.append(s)
357
+ [state] = states
358
+ cmdline = state.cmdline
359
+ assert cmdline is not None, unit
360
+
361
+ ## cut off launchd wrapper
362
+ sep_i = cmdline.index('--')
363
+ cmdline = cmdline[sep_i + 1 :]
364
+ ##
365
+
366
+ cmds = ' '.join(map(shlex.quote, cmdline))
367
+ logger.info(f'running: {cmds}')
368
+ os.execvp(
369
+ cmdline[0],
370
+ list(cmdline),
371
+ )
372
+
373
+
374
+ def get_entries_for_monitor(managed: State, *, params: MonitorParams) -> list[MonitorEntry]:
375
+ # for now kinda copy pasted from systemd
376
+
377
+ entries: list[MonitorEntry] = []
378
+ for s in managed:
379
+ assert isinstance(s, LaunchdUnitState), s
380
+
381
+ unit_file = s.unit_file
382
+ name = unit_file.name.removesuffix('.plist')
383
+
384
+ is_seconds = re.fullmatch(r'every (\d+) seconds', s.schedule or '')
385
+ if is_seconds is not None:
386
+ delta = timedelta(seconds=int(is_seconds.group(1)))
387
+ # meh, but works for now
388
+ ss = f'every {delta}'
389
+ else:
390
+ ss = str(s.schedule)
391
+
392
+ schedule = ss
393
+ command = None
394
+ if params.with_command:
395
+ cmdline = s.cmdline
396
+ assert cmdline is not None, name # not None for launchd units
397
+ command = ' '.join(map(shlex.quote, cmdline))
398
+ command = remove_launchd_wrapper(command)
399
+
400
+ status_ok = s.last_exit_code == '0'
401
+ status = 'success' if status_ok else f'exitcode {s.last_exit_code}'
402
+
403
+ pid = s.pid
404
+
405
+ entries.append(MonitorEntry(
406
+ unit=name,
407
+ status=status,
408
+ left='n/a',
409
+ next='n/a',
410
+ schedule=schedule,
411
+ command=command,
412
+ pid=pid,
413
+ status_ok=status_ok,
414
+ ))
415
+ return entries
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import shlex
4
+ import sys
5
+ from pathlib import Path
6
+ from subprocess import PIPE, STDOUT, Popen
7
+ from typing import Iterator, NoReturn
8
+
9
+ from loguru import logger
10
+
11
+ LOG_DIR = Path('~/Library/Logs/dron').expanduser()
12
+
13
+
14
+ def main() -> NoReturn:
15
+ p = argparse.ArgumentParser()
16
+ p.add_argument('--notify', action='append')
17
+ p.add_argument('--job', required=True)
18
+ # hmm, this doesn't work with keyword args??
19
+ # p.add_argument('cmd', nargs=argparse.REMAINDER)
20
+ args, rest = p.parse_known_args()
21
+
22
+ assert rest[0] == '--', rest
23
+ cmd = rest[1:]
24
+
25
+ notify_cmds = [] if args.notify is None else args.notify
26
+ job = args.job
27
+
28
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
29
+ log_file = LOG_DIR / f'{job}.log'
30
+
31
+ logger.add(log_file, rotation='100 MB') # todo configurable? or rely on osx rotation?
32
+
33
+ # hmm, a bit crap transforming everything to stdout? but not much we can do?
34
+ captured_log = []
35
+ try:
36
+ with Popen(cmd, stdout=PIPE, stderr=STDOUT) as po:
37
+ out = po.stdout
38
+ assert out is not None
39
+ for line in out:
40
+ captured_log.append(line)
41
+ sys.stdout.buffer.write(line)
42
+ rc = po.poll()
43
+
44
+ if rc == 0:
45
+ # short circuit
46
+ sys.exit(0)
47
+ except Exception as e:
48
+ # Popen istelf still fail due to permission denied or something
49
+ logger.exception(e)
50
+ captured_log.append(str(e).encode('utf8'))
51
+ rc = 123
52
+
53
+
54
+ def payload() -> Iterator[bytes]:
55
+ yield f"exit code: {rc}\n".encode()
56
+ yield b'command: \n'
57
+ yield (' '.join(map(shlex.quote, cmd)) + '\n').encode('utf8')
58
+ yield f'log file: {log_file}\n'.encode()
59
+ yield b'\n'
60
+ yield b'output (stdout + stderr):\n\n'
61
+ # TODO shit -- if multiple notifications, can't use generator for captured_log
62
+ # unless we notify simultaneously?
63
+ yield from captured_log
64
+
65
+ for line in payload():
66
+ logger.info(line.decode('utf8').rstrip('\n')) # meh
67
+
68
+ for notify_cmd in notify_cmds:
69
+ logger.info(f'notifying: {notify_cmd}')
70
+ try:
71
+ with Popen(notify_cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE) as po:
72
+ sin = po.stdin
73
+ assert sin is not None
74
+ for line in payload():
75
+ sin.write(line)
76
+ (sout, serr) = po.communicate()
77
+ for l in sout.decode('utf8').splitlines():
78
+ logger.debug(l)
79
+ for l in serr.decode('utf8').splitlines():
80
+ logger.debug(l)
81
+ assert po.poll() == 0, notify_cmd
82
+ except Exception as e:
83
+ logger.error(f'notificaiton failed: {notify_cmd}')
84
+ logger.exception(e)
85
+
86
+ sys.exit(rc)
87
+
88
+
89
+ if __name__ == '__main__':
90
+ main()
dron/monitor.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, fields
4
+ from functools import lru_cache
5
+ from typing import Any
6
+
7
+ from textual import events, work
8
+ from textual.app import App, ComposeResult
9
+ from textual.widgets import DataTable, Log
10
+
11
+ from .common import MonitorEntry, MonitorParams, unwrap
12
+ from .dron import get_entries_for_monitor, managed_units
13
+
14
+
15
+ # todo try RichLog? https://textual.textualize.io/guide/input
16
+ class LogHeader(Log):
17
+ DEFAULT_CSS = """
18
+ LogHeader {
19
+ height: 5;
20
+ dock: top;
21
+ }
22
+ """
23
+
24
+
25
+ MonitorEntries = dict[str, MonitorEntry]
26
+
27
+
28
+ @lru_cache(None)
29
+ def get_columns() -> list[str]:
30
+ cols: list[str] = []
31
+ for f in fields(MonitorEntry):
32
+ cols.append(f.name)
33
+
34
+ # TODO how to check it statically? MonitorEntry.pid isn't giving anything?
35
+ cols.remove('pid') # probs don't need?
36
+ cols.remove('status_ok')
37
+ return cols
38
+
39
+
40
+ def as_row(entry: MonitorEntry) -> dict[str, Any]:
41
+ cols = get_columns()
42
+ res = {k: v for k, v in asdict(entry).items() if k in cols}
43
+
44
+ v = res['status']
45
+ color = 'green' if entry.status_ok else 'red'
46
+ res['status'] = f'[{color}]' + res['status'] + f'[/{color}]'
47
+
48
+ # meh. workaround for next/left being max datetime
49
+ if res['next'].startswith('9999-'):
50
+ res['left'] = '--'
51
+ res['next'] = '[yellow]' + 'never' + '[/yellow]'
52
+
53
+ if entry.pid is not None:
54
+ res['left'] = '--'
55
+ res['next'] = '[yellow]' + 'running' + '[/yellow]'
56
+ return res
57
+
58
+
59
+ class MonitorApp(App):
60
+
61
+ def __init__(self, *, monitor_params: MonitorParams, refresh_every: int) -> None:
62
+ super().__init__()
63
+ self.monitor_params = monitor_params
64
+ self.refresh_every = refresh_every
65
+
66
+ def compose(self) -> ComposeResult:
67
+ # TODO self.log is already defined?? what is it?
68
+ self.logx = LogHeader(auto_scroll=True, highlight=True)
69
+
70
+ # TODO input field to filter out jobs?
71
+
72
+ # doesn't do anything??
73
+ # self.log("HELLOOO")
74
+
75
+ self.table: DataTable = DataTable(
76
+ cursor_type='row',
77
+ zebra_stripes=True, # alternating colours
78
+ )
79
+
80
+ # NOTE: useful for debugging
81
+ # yield self.logx
82
+ yield self.table
83
+
84
+ def on_mount(self) -> None:
85
+ table = self.table
86
+
87
+ for col in get_columns():
88
+ table.add_column(label=col, key=col)
89
+
90
+ # todo how to do async here as well?
91
+ entries = self.get_entries()
92
+ self.update(entries)
93
+
94
+ self.set_focus(table)
95
+
96
+ # TODO run and update it continuously? not sure
97
+ # what if it isn't computed within interval?
98
+ self.set_interval(interval=self.refresh_every, callback=self.update_in_background)
99
+
100
+ def get_entries(self) -> MonitorEntries:
101
+ managed = list(managed_units(with_body=False)) # body slows down this call quite a bit
102
+ entries = get_entries_for_monitor(managed=managed, params=self.monitor_params)
103
+ return {e.unit: e for e in entries}
104
+
105
+ def update(self, entries: MonitorEntries) -> None:
106
+ table = self.table
107
+
108
+ # self.logx.write_line(f"HI {datetime.now().isoformat()}")
109
+
110
+ current_rows: set[str] = {unwrap(x.value) for x in table.rows}
111
+ to_remove = {x for x in current_rows if x not in entries}
112
+ to_add = {x: entry for x, entry in entries.items() if x not in current_rows}
113
+ to_update = {x: entry for x, entry in entries.items() if x in current_rows}
114
+
115
+ for row_key in to_remove:
116
+ table.remove_row(row_key=row_key)
117
+
118
+ for row_key, entry in to_add.items():
119
+ vals = as_row(entry).values()
120
+ table.add_row(*vals, key=row_key)
121
+
122
+ for row_key, entry in to_update.items():
123
+ for col, value in as_row(entry).items():
124
+ table.update_cell(row_key=row_key, column_key=col, value=value, update_width=True)
125
+
126
+ columns = get_columns()
127
+
128
+ def sort_key(row):
129
+ data = dict(zip(columns, row))
130
+ is_running = 'running' in data['next']
131
+ failed = 'exit-code' in data['status']
132
+ return (not is_running, not failed, data['unit'])
133
+
134
+ # TODO hmm kinda annoying, doesn't look like it preserves cursor position
135
+ # if the item pops on top of the list when a service is running?
136
+ # but I guess not a huge deal now
137
+ table.sort(*columns, key=sort_key)
138
+
139
+ @work(exclusive=True, thread=True)
140
+ def update_in_background(self) -> None:
141
+ entries = self.get_entries()
142
+ self.call_from_thread(self.update, entries)
143
+
144
+ def on_key(self, event: events.Key) -> None:
145
+ actions = {
146
+ 'j': self.table.action_cursor_down,
147
+ 'k': self.table.action_cursor_up,
148
+ 'h': self.table.action_cursor_left,
149
+ 'l': self.table.action_cursor_right,
150
+ }
151
+ action = actions.get(event.key)
152
+ if action is not None:
153
+ action()
154
+ return
155
+ if event.key == 'q':
156
+ self.exit()
157
+ # TODO would be nice to display log on enter press or something?