gridhour 0.1.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.
gridhour/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gridhour: when British electricity is green and cheap, in the terminal."""
2
+
3
+ __version__ = "0.1.0"
gridhour/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
gridhour/app.py ADDED
@@ -0,0 +1,224 @@
1
+ """The interactive session.
2
+
3
+ Everything that can happen arrives as an event on one queue: key presses from a reader thread,
4
+ "resize" from the SIGWINCH handler, "data" when a background fetch finishes, and a "tick" when
5
+ the queue stays quiet until the next whole second. The main loop takes one event, updates the
6
+ state, draws a frame and writes only the rows that differ from the previous frame.
7
+ """
8
+ import os
9
+ import queue
10
+ import shutil
11
+ import signal
12
+ import sys
13
+ import threading
14
+ import time
15
+ from datetime import UTC, datetime
16
+
17
+ from .compose import compose
18
+ from .grid import load
19
+ from .keys import handle_key
20
+ from .themes import C
21
+
22
+ RELOAD = 5 * 60 # rebuild the window from cache this often; the network is hit only when it is stale
23
+
24
+
25
+ def utcnow():
26
+ return datetime.now(UTC)
27
+
28
+
29
+ # ---------------------------------------------------------------- background data
30
+
31
+ _fetch_lock = threading.Lock()
32
+
33
+
34
+ def fetch(st, force=False, notify=None):
35
+ """Load in a thread. Routine refreshes are skipped while one is running; a forced one (new
36
+ postcode, R) always starts, and the newest request wins."""
37
+ with _fetch_lock:
38
+ if st.loading and not force:
39
+ return
40
+ st.gen += 1
41
+ gen = st.gen
42
+ st.loading = True
43
+
44
+ def work():
45
+ fc, failure = None, None
46
+ try:
47
+ fc = load(utcnow(), st.postcode, st.region_id, st.gsp, st.prices, force=force)
48
+ except Exception as e: # noqa: BLE001 - a background failure must reach the status line, not stderr
49
+ failure = "update failed (%s)" % type(e).__name__
50
+ with _fetch_lock:
51
+ if gen == st.gen:
52
+ if fc is not None:
53
+ st.fc = fc
54
+ if fc.errors:
55
+ st.say(fc.errors[0], C.AMBER, 6)
56
+ elif force:
57
+ st.say("forecast updated", C.GREEN)
58
+ else:
59
+ st.say(failure, C.RED, 8)
60
+ st.loading = False
61
+ if notify:
62
+ notify("data")
63
+
64
+ threading.Thread(target=work, name="gridhour-fetch", daemon=True).start()
65
+
66
+
67
+ # ---------------------------------------------------------------- keyboard
68
+
69
+ class KeyDecoder:
70
+ """Cuts a byte stream into keys: plain characters, CSI sequences (ESC [ ... final) and
71
+ SS3 sequences (ESC O x). A sequence split across two reads is held until it is complete;
72
+ an ESC with nothing after it in the same read is the Escape key itself."""
73
+
74
+ def __init__(self):
75
+ self.pending = ""
76
+
77
+ def feed(self, text):
78
+ self.pending += text
79
+ keys = []
80
+ while self.pending:
81
+ key, used = self._next(self.pending)
82
+ if used == 0:
83
+ break # an unfinished sequence, wait for more bytes
84
+ keys.append(key)
85
+ self.pending = self.pending[used:]
86
+ return keys
87
+
88
+ def flush(self):
89
+ """Whatever is still pending is taken literally (a lone ESC, say)."""
90
+ keys = list(self.pending)
91
+ self.pending = ""
92
+ return keys
93
+
94
+ @staticmethod
95
+ def _next(s):
96
+ if s[0] != "\x1b":
97
+ return s[0], 1
98
+ if len(s) == 1:
99
+ return None, 0
100
+ if s[1] == "O":
101
+ return (s[:3], 3) if len(s) >= 3 else (None, 0)
102
+ if s[1] != "[":
103
+ return "\x1b", 1 # ESC followed by an ordinary key: two keys
104
+ for j in range(2, len(s)):
105
+ if "\x40" <= s[j] <= "\x7e":
106
+ return s[:j + 1], j + 1
107
+ return None, 0
108
+
109
+
110
+ def read_keys(fd, events):
111
+ """Reader thread: decoded keys go onto the event queue."""
112
+ dec = KeyDecoder()
113
+ while True:
114
+ try:
115
+ data = os.read(fd, 1024)
116
+ except OSError:
117
+ return
118
+ if not data:
119
+ return
120
+ for k in dec.feed(data.decode("utf-8", "ignore")):
121
+ events.put(("key", k))
122
+ if dec.pending == "\x1b": # nothing followed the ESC in this read
123
+ for k in dec.flush():
124
+ events.put(("key", k))
125
+
126
+
127
+ # ---------------------------------------------------------------- terminal
128
+
129
+ class Terminal:
130
+ """Cbreak mode on the alternate screen with the cursor hidden; all of it undone on exit."""
131
+
132
+ # autowrap off (?7l): if a row is ever wider than the terminal it is cut, not wrapped and scrolled
133
+ ENTER = "\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J"
134
+ LEAVE = "\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l"
135
+
136
+ def __init__(self, fd, out):
137
+ self.fd, self.out, self.saved = fd, out, None
138
+
139
+ def __enter__(self):
140
+ import termios
141
+ import tty
142
+ self.saved = termios.tcgetattr(self.fd)
143
+ tty.setcbreak(self.fd)
144
+ self.out.write(self.ENTER)
145
+ self.out.flush()
146
+ return self
147
+
148
+ def __exit__(self, *exc):
149
+ import termios
150
+ termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved)
151
+ self.out.write(self.LEAVE)
152
+ self.out.flush()
153
+ return False
154
+
155
+
156
+ class Screen:
157
+ """Remembers what is on screen and sends only the rows that changed, in one write."""
158
+
159
+ def __init__(self, out):
160
+ self.out = out
161
+ self.shown = []
162
+ self.size = None
163
+
164
+ def show(self, lines, size):
165
+ parts = []
166
+ if size != self.size:
167
+ parts.append("\x1b[2J")
168
+ self.shown, self.size = [], size
169
+ for y, line in enumerate(lines):
170
+ if y >= len(self.shown) or self.shown[y] != line:
171
+ parts.append("\x1b[%d;1H" % (y + 1) + line)
172
+ if parts:
173
+ self.out.write("".join(parts))
174
+ self.out.flush()
175
+ self.shown = lines
176
+
177
+
178
+ # ---------------------------------------------------------------- loop
179
+
180
+ def run(st):
181
+ fd, out = sys.stdin.fileno(), sys.stdout
182
+ # SimpleQueue.put is reentrant, so the SIGWINCH handler may call it while the main thread is
183
+ # inside get(); a plain Queue would deadlock on its own lock there.
184
+ events = queue.SimpleQueue()
185
+ previous = signal.signal(signal.SIGWINCH, lambda *a: events.put(("resize", None)))
186
+ try:
187
+ with Terminal(fd, out):
188
+ threading.Thread(target=read_keys, args=(fd, events), name="gridhour-keys", daemon=True).start()
189
+ _loop(st, events, Screen(out))
190
+ finally:
191
+ signal.signal(signal.SIGWINCH, previous if previous is not None else signal.SIG_DFL)
192
+
193
+
194
+ def _loop(st, events, screen):
195
+ notify = lambda kind: events.put((kind, None)) # noqa: E731
196
+ fetch(st, notify=notify)
197
+ reloaded, hour = time.monotonic(), utcnow().hour
198
+ while True:
199
+ live = utcnow()
200
+ if time.monotonic() - reloaded > RELOAD or live.hour != hour:
201
+ reloaded, hour = time.monotonic(), live.hour
202
+ fetch(st, notify=notify)
203
+ W, H = shutil.get_terminal_size((100, 30))
204
+ now = st.frozen or live.replace(microsecond=0)
205
+ screen.show(compose(W, H, st, now, live.replace(microsecond=0)).lines(), (W, H))
206
+ try:
207
+ kind, value = events.get(timeout=max(0.05, 1.0 - live.microsecond / 1e6))
208
+ except queue.Empty:
209
+ continue # the clock ticked, draw again
210
+ batch = [(kind, value)]
211
+ while True: # take everything already waiting, draw once
212
+ try:
213
+ batch.append(events.get_nowait())
214
+ except queue.Empty:
215
+ break
216
+ for kind, value in batch:
217
+ if kind != "key":
218
+ continue
219
+ action = handle_key(st, value, utcnow())
220
+ if action == "quit":
221
+ return
222
+ if action in ("fetch", "refetch"):
223
+ reloaded = time.monotonic()
224
+ fetch(st, force=action == "refetch", notify=notify)
gridhour/canvas.py ADDED
@@ -0,0 +1,108 @@
1
+ """An off-screen frame: a grid of cells that turns into ANSI text.
2
+
3
+ Each cell holds a character, a foreground, a background and a bold flag. When the frame is
4
+ turned into text, an emitter tracks the terminal's current attributes and writes only the ones
5
+ that change from one cell to the next, so a run of same-coloured cells costs one escape code.
6
+ """
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+ from .themes import C
11
+
12
+ # C0 and C1 control characters, DEL included. Text from the network or the config file goes through
13
+ # put(), so an ESC in a region name or an error message can never reach the terminal as a sequence.
14
+ UNSAFE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
15
+
16
+
17
+ def clean(text):
18
+ return UNSAFE.sub("?", str(text))
19
+
20
+
21
+ @dataclass
22
+ class Cell:
23
+ char: str = " "
24
+ fg: tuple = None
25
+ bg: tuple = None
26
+ bold: bool = False
27
+
28
+
29
+ class Canvas:
30
+ def __init__(self, w, h):
31
+ self.w, self.h = w, h
32
+ self.rows = [[Cell(bg=C.BG) for _ in range(w)] for _ in range(h)]
33
+
34
+ def cell(self, x, y):
35
+ """The cell at (x, y), or None off the edge."""
36
+ if 0 <= y < self.h and 0 <= x < self.w:
37
+ return self.rows[y][x]
38
+ return None
39
+
40
+ def bg_at(self, x, y):
41
+ c = self.cell(x, y)
42
+ return c.bg if c else C.BG
43
+
44
+ def put(self, x, y, text, fg=None, bg=None, bold=False):
45
+ """Write ``text`` from (x, y) rightwards. ``bg=None`` keeps each cell's own background."""
46
+ for i, ch in enumerate(clean(text)):
47
+ c = self.cell(x + i, y)
48
+ if c is None:
49
+ continue
50
+ c.char, c.fg, c.bold = ch, fg, bold
51
+ if bg is not None:
52
+ c.bg = bg
53
+
54
+ def fill(self, x, y, w, bg):
55
+ """Blank ``w`` cells and give them background ``bg``."""
56
+ for i in range(w):
57
+ c = self.cell(x + i, y)
58
+ if c is not None:
59
+ c.char, c.fg, c.bg, c.bold = " ", None, bg, False
60
+
61
+ def tint(self, x, y, bg):
62
+ """Change only the background of one cell."""
63
+ c = self.cell(x, y)
64
+ if c is not None:
65
+ c.bg = bg
66
+
67
+ def text(self):
68
+ """The characters alone, one string per row (for tests)."""
69
+ return ["".join(c.char for c in row) for row in self.rows]
70
+
71
+ def lines(self):
72
+ """One ANSI string per row. Every row starts and ends with attributes reset."""
73
+ return [_Emitter().row(row) for row in self.rows]
74
+
75
+
76
+ class _Emitter:
77
+ """Writes cells while remembering what the terminal is already set to."""
78
+
79
+ def __init__(self):
80
+ self.fg = self.bg = None # None is the terminal default, which is what reset leaves
81
+ self.bold = False
82
+ self.out = ["\x1b[0m"]
83
+
84
+ def row(self, cells):
85
+ for c in cells:
86
+ self.style(c)
87
+ self.out.append(c.char)
88
+ self.out.append("\x1b[0m")
89
+ return "".join(self.out)
90
+
91
+ def style(self, c):
92
+ codes = []
93
+ if c.bold != self.bold:
94
+ # there is no portable "bold off" that leaves colours alone, so a change resets
95
+ if self.bold:
96
+ codes.append("0")
97
+ self.fg = self.bg = None
98
+ else:
99
+ codes.append("1")
100
+ self.bold = c.bold
101
+ if c.fg != self.fg:
102
+ codes.append("39" if c.fg is None else "38;2;%d;%d;%d" % c.fg)
103
+ self.fg = c.fg
104
+ if c.bg != self.bg:
105
+ codes.append("49" if c.bg is None else "48;2;%d;%d;%d" % c.bg)
106
+ self.bg = c.bg
107
+ if codes:
108
+ self.out.append("\x1b[" + ";".join(codes) + "m")
gridhour/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """Command line entry point."""
2
+ import argparse
3
+ import os
4
+ import re
5
+ import shutil
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .app import run, utcnow
10
+ from .compose import MAX_H, MAX_W, compose
11
+ from .grid import cache_dir, load, normalize_postcode, parse_time, region_from_arg
12
+ from .output import json_output, line_output, watch
13
+ from .plan import MODES, parse_job
14
+ from .state import config_path, state_from_config
15
+ from .themes import THEME_ORDER, apply_theme
16
+
17
+
18
+ def parse_size(text):
19
+ m = re.fullmatch(r"([0-9]{1,4})x([0-9]{1,4})", text.strip().lower())
20
+ if not m:
21
+ raise ValueError("--size looks like 120x40")
22
+ W, H = int(m.group(1)), int(m.group(2))
23
+ if not (40 <= W <= MAX_W and 8 <= H <= MAX_H):
24
+ raise ValueError("--size must be between 40x8 and %dx%d" % (MAX_W, MAX_H))
25
+ return W, H
26
+
27
+
28
+ def reset():
29
+ """Delete the config and the cache (cache file names contain the postcode)."""
30
+ gone = 0
31
+ paths = [config_path()]
32
+ try:
33
+ paths += [os.path.join(cache_dir(), n) for n in os.listdir(cache_dir())
34
+ if n.endswith(".json") and (n.startswith(("carbon-", "prices-", "agile-", ".tmp-")))]
35
+ except OSError:
36
+ pass
37
+ for p in paths:
38
+ try:
39
+ os.remove(p)
40
+ gone += 1
41
+ except FileNotFoundError:
42
+ pass
43
+ except OSError as e:
44
+ print("gridhour: could not remove %s: %s" % (p, e.strerror), file=sys.stderr)
45
+ print("gridhour: saved settings and cache removed" if gone else "gridhour: nothing to reset")
46
+
47
+
48
+ def main(argv=None):
49
+ ap = argparse.ArgumentParser(prog="gridhour",
50
+ description="When British electricity is green and cheap: a 48 hour carbon and "
51
+ "Agile price timeline for the terminal.")
52
+ ap.add_argument("postcode", nargs="*", help="your postcode, full or the first half (remembered)")
53
+ ap.add_argument("--region", help="a region instead of a postcode: 1-18, an Agile letter A-P, or a name")
54
+ ap.add_argument("--no-prices", action="store_true", help="carbon only, skip Octopus Agile prices")
55
+ ap.add_argument("--mode", choices=MODES, help="rank windows by carbon, price or both")
56
+ ap.add_argument("--at", help="freeze the clock at this time (ISO 8601, UTC unless it has an offset)")
57
+ ap.add_argument("--line", action="store_true", help="print one line for a status bar and exit")
58
+ ap.add_argument("--best", metavar="DURATION", help="with --line/--watch: the best start for a job this long")
59
+ ap.add_argument("--tmux", action="store_true", help="like --line with tmux colour codes")
60
+ ap.add_argument("--watch", action="store_true", help="the one-liner, updating in place")
61
+ ap.add_argument("--json", action="store_true", help="print JSON and exit")
62
+ ap.add_argument("--once", action="store_true", help="print one frame of the full UI and exit")
63
+ ap.add_argument("--size", help="WxH for --once (default: terminal size)")
64
+ ap.add_argument("--theme", choices=THEME_ORDER, help="colour theme")
65
+ ap.add_argument("--12h", dest="h12", action="store_true", help="12-hour clock")
66
+ ap.add_argument("--compact", action="store_true", help="small-pane layout without the chart")
67
+ ap.add_argument("--reset", action="store_true", help="forget saved settings and jobs")
68
+ ap.add_argument("--version", action="version", version="gridhour " + __version__)
69
+ args = ap.parse_args(argv)
70
+ if args.reset:
71
+ reset()
72
+ return
73
+ st = state_from_config()
74
+ try:
75
+ if args.postcode:
76
+ st.postcode = normalize_postcode(" ".join(args.postcode))
77
+ st.region_id = None
78
+ st.save()
79
+ if args.region:
80
+ st.region_id = region_from_arg(args.region)
81
+ st.postcode = None
82
+ st.persist = False
83
+ best = parse_job("job " + args.best) if args.best else None
84
+ at = parse_time(args.at) if args.at else None
85
+ if at and not 2000 <= at.year <= 2100:
86
+ raise ValueError("--at must be between the years 2000 and 2100")
87
+ size = parse_size(args.size) if args.size else None
88
+ except ValueError as e:
89
+ sys.exit("gridhour: %s" % e)
90
+ if args.no_prices:
91
+ st.prices = False
92
+ if args.mode:
93
+ st.mode = args.mode
94
+ if args.theme:
95
+ apply_theme(args.theme)
96
+ if args.h12:
97
+ st.h12 = True
98
+ if args.compact:
99
+ st.compact = "on"
100
+ if args.watch:
101
+ watch(st, utcnow, "ansi" if sys.stdout.isatty() else "plain", best)
102
+ return
103
+ if args.line or args.tmux or args.json or args.once:
104
+ now = at or utcnow().replace(microsecond=0)
105
+ st.fc = load(now, st.postcode, st.region_id, st.gsp, st.prices)
106
+ if args.json:
107
+ print(json_output(st, now))
108
+ elif args.once:
109
+ W, H = size or shutil.get_terminal_size((120, 40))
110
+ print("\n".join(compose(W, H, st, now, now).lines()))
111
+ else:
112
+ color = "tmux" if args.tmux else ("ansi" if sys.stdout.isatty() else "plain")
113
+ print(line_output(st, now, color, best))
114
+ return
115
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
116
+ sys.exit("gridhour: needs an interactive terminal (use --once, --line or --json otherwise)")
117
+ if at:
118
+ st.frozen = at
119
+ try:
120
+ run(st)
121
+ except KeyboardInterrupt:
122
+ pass
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()