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.
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ from .data_store import DataStore
@@ -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