nocturne-shift 0.5.5__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.
- nocturne_shift/__init__.py +2 -0
- nocturne_shift/bin/get_sunrise_sunset.py +241 -0
- nocturne_shift/bin/is_day_or_night.py +59 -0
- nocturne_shift/bin/settings.py +53 -0
- nocturne_shift/cli.py +217 -0
- nocturne_shift/data_store.py +120 -0
- nocturne_shift/schemas/com.github.christophermca.night-shift.gschema.xml +38 -0
- nocturne_shift/schemas/gschemas.compiled +0 -0
- nocturne_shift-0.5.5.dist-info/METADATA +113 -0
- nocturne_shift-0.5.5.dist-info/RECORD +14 -0
- nocturne_shift-0.5.5.dist-info/WHEEL +5 -0
- nocturne_shift-0.5.5.dist-info/entry_points.txt +3 -0
- nocturne_shift-0.5.5.dist-info/licenses/LICENSE +674 -0
- nocturne_shift-0.5.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import gi
|
|
5
|
+
import sys
|
|
6
|
+
import pprint
|
|
7
|
+
import json
|
|
8
|
+
import argparse
|
|
9
|
+
import subprocess
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from collections import namedtuple
|
|
13
|
+
from typing import Any
|
|
14
|
+
from .settings import Settings
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
gi.require_version("Gio", "2.0")
|
|
18
|
+
from gi.repository import GLib
|
|
19
|
+
|
|
20
|
+
NOAA = "https://api.sunrise-sunset.org/v2"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GetTimeOfSunriseSunset:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
verbose: int = 0,
|
|
27
|
+
override: bool = False,
|
|
28
|
+
use_geoclue: bool | None = False,
|
|
29
|
+
settings: Settings | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.override = override
|
|
32
|
+
self.verbose = verbose
|
|
33
|
+
self.use_geoclue = use_geoclue
|
|
34
|
+
self.settings = settings
|
|
35
|
+
self.agent = None
|
|
36
|
+
self.times: tuple[str, str] | None = None
|
|
37
|
+
|
|
38
|
+
# Determine Time of sunrise/sunset
|
|
39
|
+
coords: tuple[float, float] | None = self.determine_location()
|
|
40
|
+
|
|
41
|
+
if coords:
|
|
42
|
+
self.times = self._get_sunrise_sunset(*coords, self.verbose)
|
|
43
|
+
|
|
44
|
+
# pass coords from cli to module
|
|
45
|
+
def __call__(self, coords, verbose=0):
|
|
46
|
+
return self._get_sunrise_sunset(*coords, verbose)
|
|
47
|
+
|
|
48
|
+
def determine_location(self) -> tuple[float, float] | None:
|
|
49
|
+
if self.use_geoclue is None and self.settings is not None:
|
|
50
|
+
self.use_geoclue = self.settings.get("use-geoclue")
|
|
51
|
+
|
|
52
|
+
# Determine Location
|
|
53
|
+
if self.use_geoclue is True:
|
|
54
|
+
coords = self._get_location()
|
|
55
|
+
else:
|
|
56
|
+
coords = self._get_static_location_from_settings()
|
|
57
|
+
|
|
58
|
+
return coords
|
|
59
|
+
|
|
60
|
+
def _get_location(self) -> tuple[float, float]:
|
|
61
|
+
try:
|
|
62
|
+
|
|
63
|
+
# Get location data from Geoclue
|
|
64
|
+
if self.agent is None:
|
|
65
|
+
self.agent = subprocess.Popen(
|
|
66
|
+
["/usr/lib/geoclue-2.0/demos/agent"],
|
|
67
|
+
stdout=subprocess.DEVNULL,
|
|
68
|
+
stderr=subprocess.DEVNULL,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
geoclue_data = subprocess.Popen(
|
|
72
|
+
[
|
|
73
|
+
"/usr/lib/geoclue-2.0/demos/where-am-i",
|
|
74
|
+
"--accuracy-level=8",
|
|
75
|
+
"--time-threshold=3",
|
|
76
|
+
], # `run /usr/lib/geoclue-2.0/demo/where-am-i -h` for more information about options
|
|
77
|
+
text=True,
|
|
78
|
+
stdout=subprocess.PIPE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
lat_regex = r"^(Lat.*:).*([\.\-\d+]+)"
|
|
82
|
+
lng_regex = r"^(Long.*:).*([\.\-\d+]+)"
|
|
83
|
+
|
|
84
|
+
# READS response for LAT and LNG
|
|
85
|
+
Coordinates = namedtuple("Coordinates", ["latitude", "longitude"])
|
|
86
|
+
found_coords: dict[str, float | None] = {
|
|
87
|
+
"lat": None,
|
|
88
|
+
"lng": None,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for line in iter(geoclue_data.stdout.readline, ""):
|
|
92
|
+
found_lat = re.match(lat_regex, line)
|
|
93
|
+
found_lng = re.match(lng_regex, line)
|
|
94
|
+
|
|
95
|
+
if found_lat:
|
|
96
|
+
matched_string = found_lat.group().split()[1]
|
|
97
|
+
found_coords["lat"] = float(matched_string)
|
|
98
|
+
|
|
99
|
+
if found_lng:
|
|
100
|
+
matched_string = found_lng.group().split()[1]
|
|
101
|
+
found_coords["lng"] = float(matched_string)
|
|
102
|
+
|
|
103
|
+
# attempt to exit early
|
|
104
|
+
if found_coords["lat"] and found_coords["lng"]:
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
geoclue_data.terminate()
|
|
108
|
+
# raise exception if unable to determine coords
|
|
109
|
+
if found_coords["lat"] is None and found_coords["lng"] is None:
|
|
110
|
+
raise TypeError(
|
|
111
|
+
"Could not determine location. Please check your geoclue configuration"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
coords = Coordinates(found_coords["lat"], found_coords["lng"])
|
|
115
|
+
|
|
116
|
+
previous_coordinates = None
|
|
117
|
+
if self.settings is not None:
|
|
118
|
+
previous_coordinates = self.settings.get(
|
|
119
|
+
"last-known-coordinates"
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
previous_coordinates
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"{e}")
|
|
125
|
+
|
|
126
|
+
# CHECK if should save?
|
|
127
|
+
if self.override or (list(coords) != previous_coordinates):
|
|
128
|
+
last_known_coordinates = GLib.Variant("(dd)", coords)
|
|
129
|
+
data: dict = {
|
|
130
|
+
"last-known-coordinates": last_known_coordinates,
|
|
131
|
+
}
|
|
132
|
+
self._save(data)
|
|
133
|
+
return coords
|
|
134
|
+
|
|
135
|
+
except subprocess.TimeoutExpired as e:
|
|
136
|
+
print(f"TimeoutExpired: {e.timeout} seconds\n\n {e.stdout}")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
except subprocess.CalledProcessError as e:
|
|
140
|
+
print(f"Error: {e.returncode} \n\n {e.stderr}")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
finally:
|
|
144
|
+
try:
|
|
145
|
+
if self.agent is not None:
|
|
146
|
+
self.agent.kill() # Gracefully exits
|
|
147
|
+
self.agent.wait() # Prevents zombie processes
|
|
148
|
+
self.agent = None
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(f"Error: ___{e}")
|
|
152
|
+
|
|
153
|
+
def _get_sunrise_sunset(
|
|
154
|
+
self, lat: float, lng: float, verbose: int
|
|
155
|
+
) -> tuple[float, float]:
|
|
156
|
+
try:
|
|
157
|
+
params = {"lat": lat, "lng": lng}
|
|
158
|
+
|
|
159
|
+
# Handle response
|
|
160
|
+
response = requests.get(NOAA, params)
|
|
161
|
+
response.raise_for_status()
|
|
162
|
+
|
|
163
|
+
response_data = response.json()
|
|
164
|
+
|
|
165
|
+
if self.verbose > 1:
|
|
166
|
+
json_string = json.dumps(
|
|
167
|
+
response_data, indent=4, sort_keys=True
|
|
168
|
+
)
|
|
169
|
+
print(f"[nocturne-shift] {json_string}")
|
|
170
|
+
|
|
171
|
+
tzid = response_data["tzid"]
|
|
172
|
+
sunrise = datetime.fromisoformat(
|
|
173
|
+
response_data["sunrise"]
|
|
174
|
+
).strftime("%H:%M")
|
|
175
|
+
sunset = datetime.fromisoformat(response_data["sunset"]).strftime(
|
|
176
|
+
"%H:%M"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
times = (sunrise, sunset)
|
|
180
|
+
|
|
181
|
+
saved: dict[str, Any] = {}
|
|
182
|
+
|
|
183
|
+
times_tuple = GLib.Variant("(ss)", times)
|
|
184
|
+
saved = {
|
|
185
|
+
"timestamp": f"{datetime.now().astimezone().isoformat()}",
|
|
186
|
+
"tzid": tzid,
|
|
187
|
+
"times": times_tuple,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
self._save(saved)
|
|
191
|
+
|
|
192
|
+
return times
|
|
193
|
+
|
|
194
|
+
except requests.exceptions.HTTPError as http_err:
|
|
195
|
+
print(f"HTTP error occurred (e.g., 404, 500): {http_err}")
|
|
196
|
+
except (
|
|
197
|
+
Exception
|
|
198
|
+
) as e: # Generic Exception must go last becuase it will catch any exception
|
|
199
|
+
print(f" Generic: {e}")
|
|
200
|
+
|
|
201
|
+
def _save(self, data) -> None:
|
|
202
|
+
if self.settings is None:
|
|
203
|
+
print(
|
|
204
|
+
f"nocturne-shift: unable to save {', '.join(data)}: settings unavailable",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# DataStore.set picks the right Gio setter for each type
|
|
211
|
+
for key, value in data.items():
|
|
212
|
+
self.settings.set(key, value)
|
|
213
|
+
|
|
214
|
+
if self.verbose > 0:
|
|
215
|
+
pprint.pprint(data)
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
print(f"ERROR SAVING: {e}", file=sys.stderr)
|
|
219
|
+
|
|
220
|
+
def _get_static_location_from_settings(self) -> tuple[float, float]:
|
|
221
|
+
|
|
222
|
+
if self.settings is None:
|
|
223
|
+
print(
|
|
224
|
+
"nocturne-shift: no static location: self.settings unavailable",
|
|
225
|
+
file=sys.stderr,
|
|
226
|
+
)
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
lat = self.settings.get("static-latitude")
|
|
230
|
+
lng = self.settings.get("static-longitude")
|
|
231
|
+
|
|
232
|
+
if lat and lng:
|
|
233
|
+
static_location = (float(lat), float(lng))
|
|
234
|
+
|
|
235
|
+
last_known_coordinates = GLib.Variant("(dd)", static_location)
|
|
236
|
+
self._save({"last-known-coordinates": last_known_coordinates})
|
|
237
|
+
|
|
238
|
+
return static_location
|
|
239
|
+
|
|
240
|
+
else:
|
|
241
|
+
print("Missing required keys")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .settings import Settings
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_day_or_night(
|
|
10
|
+
times: tuple[str, str] | None = None,
|
|
11
|
+
settings: Settings | None = None,
|
|
12
|
+
verbose=0,
|
|
13
|
+
):
|
|
14
|
+
"""Work out "day" or "night" and save it.
|
|
15
|
+
|
|
16
|
+
`times` is a (sunrise, sunset) pair of "HH:MM" strings. When it's not
|
|
17
|
+
given, the times saved in settings are used.
|
|
18
|
+
|
|
19
|
+
`settings` is a `Settings`. When it's not given, raise an Error to the caller;
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
settings = settings
|
|
23
|
+
|
|
24
|
+
if times is None:
|
|
25
|
+
if settings is None:
|
|
26
|
+
raise AttributeError(
|
|
27
|
+
"nocturne-shift: unable to check `day or night`. settings is None"
|
|
28
|
+
)
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
times = settings.get("times")
|
|
32
|
+
|
|
33
|
+
if not times:
|
|
34
|
+
return
|
|
35
|
+
[sunrise, sunset] = times
|
|
36
|
+
current_time = datetime.now().strftime("%H:%M") # 24hr format
|
|
37
|
+
|
|
38
|
+
# check if currrent time is after sunrise or sunset
|
|
39
|
+
DAY_NIGHT: str
|
|
40
|
+
|
|
41
|
+
if verbose >= 1:
|
|
42
|
+
print(sunrise, current_time, sunset)
|
|
43
|
+
|
|
44
|
+
if sunrise <= current_time < sunset:
|
|
45
|
+
DAY_NIGHT = "day"
|
|
46
|
+
else:
|
|
47
|
+
DAY_NIGHT = "night"
|
|
48
|
+
|
|
49
|
+
# set day-or-night
|
|
50
|
+
try:
|
|
51
|
+
settings.set("day-or-night", DAY_NIGHT)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(
|
|
54
|
+
"nocturne-shift: unable to save day-or-night: settings unavailable",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
print(f"{DAY_NIGHT}")
|
|
59
|
+
return DAY_NIGHT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from gi.repository import Gio
|
|
4
|
+
from ..data_store import DataStore
|
|
5
|
+
|
|
6
|
+
DEFAULT_SCHEMA_PATH = os.path.expanduser(
|
|
7
|
+
Path.home()
|
|
8
|
+
/ ".local"
|
|
9
|
+
/ "share"
|
|
10
|
+
/ "gnome-shell"
|
|
11
|
+
/ "extensions"
|
|
12
|
+
/ "night-shift@christophermca.github.io"
|
|
13
|
+
/ "schemas"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Settings:
|
|
18
|
+
"""Wraps the extension's Gio.Settings.
|
|
19
|
+
|
|
20
|
+
`Settings()` returns the Gio.Settings object, or None when the schema
|
|
21
|
+
isn't installed (for example the GNOME extension is missing). Callers
|
|
22
|
+
must handle None; nocturne-shift keeps working, it just can't save.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, data_store: DataStore | None = None):
|
|
26
|
+
|
|
27
|
+
self.data_store = (
|
|
28
|
+
data_store
|
|
29
|
+
if data_store is not None
|
|
30
|
+
else DataStore(DEFAULT_SCHEMA_PATH)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def data(self):
|
|
35
|
+
return self.data_store.data
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def available(self) -> bool:
|
|
39
|
+
return self.data_store.available
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def get_keys(self) -> list[str]:
|
|
43
|
+
return self.data_store.get_keys()
|
|
44
|
+
|
|
45
|
+
def get(self, key: str):
|
|
46
|
+
try:
|
|
47
|
+
return self.data_store.get(key)
|
|
48
|
+
|
|
49
|
+
except AttributeError as e:
|
|
50
|
+
print(f"Error: Failed to GET key:`{key}`")
|
|
51
|
+
|
|
52
|
+
def set(self, key, value):
|
|
53
|
+
self.data_store.set(key, value)
|
nocturne_shift/cli.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
# PYTHON_ARG_COMPLETE_OK
|
|
3
|
+
from .bin.get_sunrise_sunset import GetTimeOfSunriseSunset
|
|
4
|
+
from .bin.is_day_or_night import is_day_or_night
|
|
5
|
+
from .bin.settings import Settings
|
|
6
|
+
from .data_store import DataStore
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import argparse
|
|
10
|
+
import argcomplete
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
|
|
13
|
+
PLUGIN_GROUP = "night_shift.commands"
|
|
14
|
+
OPTIONAL_COMMANDS = {"services": "gnome-night-shift"}
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = importlib.metadata.version("nocturne-shift")
|
|
18
|
+
except importlib.metadata.PackageNotFoundError:
|
|
19
|
+
__version__ = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_commands(subparsers) -> dict:
|
|
23
|
+
"""Let installed plugins add subcommands; returns {command: handler}.
|
|
24
|
+
|
|
25
|
+
A plugin registers in its own pyproject.toml:
|
|
26
|
+
[project.entry-points."night_shift.commands"]
|
|
27
|
+
services = "night_shift_services.cli:register"
|
|
28
|
+
`register(subparsers)` adds its parser and returns `handler(args)`.
|
|
29
|
+
"""
|
|
30
|
+
handlers = {}
|
|
31
|
+
for entry_point in importlib.metadata.entry_points(group=PLUGIN_GROUP):
|
|
32
|
+
try:
|
|
33
|
+
handlers[entry_point.name] = entry_point.load()(subparsers)
|
|
34
|
+
except (
|
|
35
|
+
Exception
|
|
36
|
+
) as e: # a broken plugin must not take the core CLI down
|
|
37
|
+
print(
|
|
38
|
+
f"nocturne-shift: skipping plugin {entry_point.name!r} ({e})",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Commands whose plugin isn't installed still parse, and say how to get them
|
|
43
|
+
for name, extra in OPTIONAL_COMMANDS.items():
|
|
44
|
+
if name not in handlers:
|
|
45
|
+
stub = subparsers.add_parser(
|
|
46
|
+
name, help=f"(not installed: pip install '{extra}')"
|
|
47
|
+
)
|
|
48
|
+
stub.add_argument(
|
|
49
|
+
"rest", nargs=argparse.REMAINDER
|
|
50
|
+
) # accept anything
|
|
51
|
+
handlers[name] = _not_installed(name, extra)
|
|
52
|
+
return handlers
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _not_installed(name, extra):
|
|
56
|
+
def handler(args):
|
|
57
|
+
sys.exit(f"nocturne-shift {name} requires: pip install '{extra}'")
|
|
58
|
+
|
|
59
|
+
return handler
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check(times=None, settings=None, verbose=0):
|
|
63
|
+
try:
|
|
64
|
+
is_day_or_night(times, settings, verbose)
|
|
65
|
+
except AttributeError as e:
|
|
66
|
+
print(f"ERROR: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_once(
|
|
70
|
+
verbose: int = 0,
|
|
71
|
+
override: bool = False,
|
|
72
|
+
check_now: bool | str | None = False,
|
|
73
|
+
use_geoclue: bool | None = False,
|
|
74
|
+
settings: Settings | None = None,
|
|
75
|
+
):
|
|
76
|
+
try:
|
|
77
|
+
sunrise_sunset = GetTimeOfSunriseSunset(
|
|
78
|
+
verbose, override, use_geoclue, settings
|
|
79
|
+
)
|
|
80
|
+
if check_now != False:
|
|
81
|
+
try:
|
|
82
|
+
check(sunrise_sunset.times, settings, verbose)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"check failed: {e}")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f"ERROR during run_once(): {e} ")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main():
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
description="Get the times for the sunrise/sunset",
|
|
92
|
+
prog="nocturne-shift",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
sub_parsers = parser.add_subparsers(dest="command")
|
|
96
|
+
commands = load_commands(sub_parsers)
|
|
97
|
+
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--lat",
|
|
100
|
+
dest="latitude",
|
|
101
|
+
type=float,
|
|
102
|
+
nargs="?",
|
|
103
|
+
help="compares current time with sunrise/sunset time",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--lng",
|
|
108
|
+
dest="longitude",
|
|
109
|
+
type=float,
|
|
110
|
+
nargs="?",
|
|
111
|
+
help="compares current time with sunrise/sunset time",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"-c",
|
|
116
|
+
"--check-now",
|
|
117
|
+
metavar="SCHEMA_PATH",
|
|
118
|
+
nargs="?",
|
|
119
|
+
default=False,
|
|
120
|
+
help="compares current time with sunrise/sunset time",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"-f",
|
|
124
|
+
"--force",
|
|
125
|
+
dest="override",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="ignores cached data",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"-g",
|
|
132
|
+
"--geoclue",
|
|
133
|
+
dest="use_geoclue",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="use geoclue to determine location",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"-w",
|
|
140
|
+
"--watch",
|
|
141
|
+
nargs="?",
|
|
142
|
+
default=False,
|
|
143
|
+
metavar="SCHEMA_PATH",
|
|
144
|
+
help="watch for changes in data_store",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
"-v",
|
|
149
|
+
"--verbose",
|
|
150
|
+
action="count",
|
|
151
|
+
default=0,
|
|
152
|
+
help="Prints more information",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"--version",
|
|
157
|
+
action="version",
|
|
158
|
+
version=f"%(prog)s {__version__}",
|
|
159
|
+
help="Prints more information",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
argcomplete.autocomplete(parser)
|
|
163
|
+
args = parser.parse_args()
|
|
164
|
+
|
|
165
|
+
# ------------ Plugin Commands
|
|
166
|
+
if args.command in commands:
|
|
167
|
+
return commands[args.command](args)
|
|
168
|
+
|
|
169
|
+
# ------------ Watch
|
|
170
|
+
if args.watch != False:
|
|
171
|
+
settings = (
|
|
172
|
+
Settings(DataStore(args.watch)) if args.watch else Settings()
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# TODO validate using run_once will work here.
|
|
176
|
+
return run_once(
|
|
177
|
+
verbose=args.verbose,
|
|
178
|
+
override=args.override,
|
|
179
|
+
check_now=False, # the watch option only gets the current location if location has changed for an OVERRIDE flag was passed
|
|
180
|
+
use_geoclue=None, # watch will handle use settings
|
|
181
|
+
settings=settings,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# ------------ Static coordinates
|
|
185
|
+
elif args.latitude != None and args.longitude != None:
|
|
186
|
+
# One Settings per run, shared by everything below, so a missing schema is reported once. Only created on paths that need it.
|
|
187
|
+
settings = Settings()
|
|
188
|
+
coords = tuple([args.latitude, args.longitude])
|
|
189
|
+
sunrise_sunset = GetTimeOfSunriseSunset(
|
|
190
|
+
args.verbose, args.override, settings=settings
|
|
191
|
+
)
|
|
192
|
+
times = sunrise_sunset(coords, args.verbose)
|
|
193
|
+
if args.check_now != False:
|
|
194
|
+
return check(times, settings, args.verbose)
|
|
195
|
+
|
|
196
|
+
# ------------ Using Geoclue
|
|
197
|
+
elif args.use_geoclue:
|
|
198
|
+
settings = Settings()
|
|
199
|
+
return run_once(
|
|
200
|
+
args.verbose,
|
|
201
|
+
args.override,
|
|
202
|
+
args.check_now,
|
|
203
|
+
args.use_geoclue,
|
|
204
|
+
settings,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
elif args.check_now != False:
|
|
208
|
+
settings = (
|
|
209
|
+
Settings(DataStore(args.check_now))
|
|
210
|
+
if type(args.check_now) is str
|
|
211
|
+
else Settings()
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
check(settings=settings, verbose=args.verbose)
|
|
215
|
+
return None
|
|
216
|
+
else:
|
|
217
|
+
parser.print_help()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from gi.repository import Gio, GLib
|
|
5
|
+
from typing import TypedDict, get_type_hints
|
|
6
|
+
|
|
7
|
+
default_config_file = ".var/night-shift/data.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _compile_schema(schema_path, schema_id) -> Gio.SettingsSchema | None:
|
|
11
|
+
try:
|
|
12
|
+
schema_source = Gio.SettingsSchemaSource.new_from_directory(
|
|
13
|
+
schema_path, Gio.SettingsSchemaSource.get_default(), False
|
|
14
|
+
)
|
|
15
|
+
schema = schema_source.lookup(schema_id, True)
|
|
16
|
+
if schema is None:
|
|
17
|
+
raise LookupError(f"schema {schema_id} not found")
|
|
18
|
+
|
|
19
|
+
return schema
|
|
20
|
+
|
|
21
|
+
except Exception as e:
|
|
22
|
+
print(
|
|
23
|
+
f"nocturne-shift: settings unavailable, nothing will be saved ({e})",
|
|
24
|
+
file=sys.stderr,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TypedStore(TypedDict):
|
|
29
|
+
day_or_night: str
|
|
30
|
+
last_known_coordinates: tuple[float, float]
|
|
31
|
+
static_latitude: str
|
|
32
|
+
static_longitude: str
|
|
33
|
+
timestamp: str
|
|
34
|
+
times: tuple[str, str]
|
|
35
|
+
tzid: str
|
|
36
|
+
use_geoclue: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DataStore:
|
|
40
|
+
|
|
41
|
+
def __init__(self, schema_path: str):
|
|
42
|
+
self.data: GObject | TypedStore | None = None
|
|
43
|
+
self.schema_path = schema_path
|
|
44
|
+
|
|
45
|
+
self.schema: Gio.SettingsSchema | None = None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
schema_xml = next(
|
|
49
|
+
Path(self.schema_path).glob("*.gschema.xml"), None
|
|
50
|
+
)
|
|
51
|
+
if schema_xml is None:
|
|
52
|
+
raise LookupError(f"no .gschema.xml in {self.schema_path}")
|
|
53
|
+
package_id = schema_xml.name.removesuffix(".gschema.xml")
|
|
54
|
+
self.schema = _compile_schema(
|
|
55
|
+
self.schema_path, schema_id=package_id
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if self.schema is not None:
|
|
59
|
+
self.data = Gio.Settings.new_full(self.schema, None, None)
|
|
60
|
+
else:
|
|
61
|
+
self.data = None
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(
|
|
65
|
+
f"nocturne-shift: settings unavailable, nothing will be saved ({e})",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
self.data = None
|
|
69
|
+
|
|
70
|
+
def get_keys(self) -> list[str]:
|
|
71
|
+
if self.schema is None:
|
|
72
|
+
return []
|
|
73
|
+
else:
|
|
74
|
+
return sorted(self.schema.list_keys())
|
|
75
|
+
|
|
76
|
+
def get(self, key: str):
|
|
77
|
+
if self.data is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
annotations = get_type_hints(TypedStore)
|
|
81
|
+
|
|
82
|
+
norm_key = key.replace("-", "_")
|
|
83
|
+
value = annotations.get(norm_key)()
|
|
84
|
+
|
|
85
|
+
match value:
|
|
86
|
+
case bool():
|
|
87
|
+
value = self.data[key]
|
|
88
|
+
return value
|
|
89
|
+
case str():
|
|
90
|
+
value = self.data[key]
|
|
91
|
+
return value
|
|
92
|
+
case int():
|
|
93
|
+
value = self.data[key]
|
|
94
|
+
return value
|
|
95
|
+
case _:
|
|
96
|
+
value = self.data[key]
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
def set(self, key: str, value):
|
|
100
|
+
if self.data is None:
|
|
101
|
+
raise AttributeError("`data` is None")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
match value:
|
|
105
|
+
case bool(): # bool must go before int: bool is a subclass of int
|
|
106
|
+
self.data.set_boolean(key, value)
|
|
107
|
+
case str():
|
|
108
|
+
self.data.set_string(key, value)
|
|
109
|
+
case int():
|
|
110
|
+
self.data.set_int(key, value)
|
|
111
|
+
case GLib.Variant(): # tuples like "times" (ss) and coords (dd)
|
|
112
|
+
self.data.set_value(key, value)
|
|
113
|
+
case _:
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"can't save {key}: unsupported type {type(value).__name__}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def available(self) -> bool:
|
|
120
|
+
return self.data is not None
|