pyWinService 2.0.1__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.
pyWinService/__init__.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Classes and functions that create an easy way to create a Windows service in python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import logging.config
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from abc import ABC
|
|
14
|
+
from abc import abstractmethod
|
|
15
|
+
from argparse import REMAINDER
|
|
16
|
+
from argparse import ArgumentError
|
|
17
|
+
from argparse import ArgumentParser
|
|
18
|
+
from argparse import Namespace
|
|
19
|
+
from concurrent.futures import CancelledError
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from dataclasses import MISSING
|
|
22
|
+
from dataclasses import Field
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from dataclasses import field
|
|
25
|
+
from dataclasses import fields
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from datetime import timedelta
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from threading import Event
|
|
30
|
+
from threading import Thread
|
|
31
|
+
from threading import Timer
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
from typing import Final
|
|
34
|
+
from typing import NoReturn
|
|
35
|
+
from typing import Protocol
|
|
36
|
+
from typing import Self
|
|
37
|
+
|
|
38
|
+
from dateutil.relativedelta import relativedelta
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
41
|
+
from argparse import _ArgumentGroup
|
|
42
|
+
from argparse import _SubParsersAction
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
from collections.abc import Generator
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from collections.abc import Sequence
|
|
47
|
+
from concurrent.futures import Executor
|
|
48
|
+
from logging import Logger
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
logger: Logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
type CommandResult = int | str
|
|
54
|
+
|
|
55
|
+
SUCCESS: Final[CommandResult] = 0
|
|
56
|
+
ARGUMENT_ERROR: Final[CommandResult] = "ARGUMENT_ERROR"
|
|
57
|
+
ARGPARSE_EXIT: Final[CommandResult] = "ARGPARSE_EXIT"
|
|
58
|
+
FAILURE: Final[CommandResult] = -1
|
|
59
|
+
|
|
60
|
+
COMMAND_PREFIX: Final[str] = "command_"
|
|
61
|
+
|
|
62
|
+
WindowsService: type | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Command:
|
|
67
|
+
"""The parameters required create a *SubParser* with arguments."""
|
|
68
|
+
|
|
69
|
+
args: Sequence[Argument]
|
|
70
|
+
"""Sequence of arguments used for this command."""
|
|
71
|
+
kwargs: Mapping[str, Any]
|
|
72
|
+
"""Keyword arguments passed to the *add_parser* function."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, *args: Argument, **kwargs: Any) -> None:
|
|
75
|
+
"""Create a new CommandDefinition instance."""
|
|
76
|
+
object.__setattr__(self, "args", args)
|
|
77
|
+
object.__setattr__(self, "kwargs", kwargs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Argument:
|
|
82
|
+
"""The definition of a command argument."""
|
|
83
|
+
|
|
84
|
+
args: Sequence[Any]
|
|
85
|
+
"""Arguments passed to *add_argument*."""
|
|
86
|
+
kwargs: Mapping[str, Any]
|
|
87
|
+
"""Keyword arguments passed to *add_argument*."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, name_or_flag: str, *args: str, **kwargs: Any) -> None:
|
|
90
|
+
"""Create a new ArgumentDefinition instance."""
|
|
91
|
+
object.__setattr__(self, "args", (name_or_flag, *args))
|
|
92
|
+
object.__setattr__(self, "kwargs", kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InvalidCommandNameError(NameError):
|
|
96
|
+
"""Exception caused when a service command does not have the correct prefix."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, command_func: ServiceCommand) -> None:
|
|
99
|
+
"""Create a new InvalidCommandNameError instance."""
|
|
100
|
+
super().__init__(f"Invalid prefix: '{command_func.__name__}'")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ServiceCommand(Protocol):
|
|
104
|
+
"""Represents the structure of a command."""
|
|
105
|
+
|
|
106
|
+
__name__: str
|
|
107
|
+
command: Command
|
|
108
|
+
|
|
109
|
+
def __call__(self, *args: Any, **kwargs: Any) -> CommandResult:
|
|
110
|
+
"""Execute the command."""
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def create[T](*args: Any, **kwargs: Any) -> Callable[[T], T]:
|
|
114
|
+
"""Create a decorator to convert a *Service* class method into a command."""
|
|
115
|
+
command: Command = Command(*args, **kwargs)
|
|
116
|
+
|
|
117
|
+
def decorator[C: ServiceCommand](command_func: C) -> C:
|
|
118
|
+
if not command_func.__name__.startswith(COMMAND_PREFIX):
|
|
119
|
+
raise InvalidCommandNameError(command_func)
|
|
120
|
+
command_func.command = command
|
|
121
|
+
return command_func
|
|
122
|
+
|
|
123
|
+
return decorator
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, kw_only=True)
|
|
127
|
+
class ServiceConfig:
|
|
128
|
+
"""Handles loading and saving the config file for a service."""
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def path_funcs() -> Mapping[str, Any]:
|
|
132
|
+
"""Return functions to save and load Path objects in config files."""
|
|
133
|
+
|
|
134
|
+
def on_save(obj: Path | None) -> str | None:
|
|
135
|
+
return None if obj is None else str(obj)
|
|
136
|
+
|
|
137
|
+
def on_load(obj: str | None) -> Path | None:
|
|
138
|
+
return None if obj is None else Path(obj)
|
|
139
|
+
|
|
140
|
+
return {"on_save": on_save, "on_load": on_load}
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def datetime_funcs() -> Mapping[str, Any]:
|
|
144
|
+
"""Return functions to save and load datetime objects in config files."""
|
|
145
|
+
|
|
146
|
+
def on_save(obj: datetime) -> str:
|
|
147
|
+
return obj.isoformat()
|
|
148
|
+
|
|
149
|
+
def on_load(obj: str) -> datetime:
|
|
150
|
+
return datetime.fromisoformat(obj)
|
|
151
|
+
|
|
152
|
+
return {"on_save": on_save, "on_load": on_load}
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def relativedelta_funcs() -> Mapping[str, Any]:
|
|
156
|
+
"""Return functions to save and load relativedelta objects in config files."""
|
|
157
|
+
|
|
158
|
+
def on_save(obj: relativedelta) -> Mapping[str, Any]:
|
|
159
|
+
data: Mapping[str, int] = {
|
|
160
|
+
k: v
|
|
161
|
+
for k, v in {
|
|
162
|
+
"years": obj.years,
|
|
163
|
+
"months": obj.months,
|
|
164
|
+
"days": obj.days,
|
|
165
|
+
"leapdays": obj.leapdays,
|
|
166
|
+
"hours": obj.hours,
|
|
167
|
+
"minutes": obj.minutes,
|
|
168
|
+
"seconds": obj.seconds,
|
|
169
|
+
"microseconds": obj.microseconds,
|
|
170
|
+
}.items()
|
|
171
|
+
if v != 0
|
|
172
|
+
}
|
|
173
|
+
return {"seconds": 0} if len(data) == 0 else data
|
|
174
|
+
|
|
175
|
+
def on_load(obj: Mapping[str, Any]) -> relativedelta:
|
|
176
|
+
return relativedelta(**obj)
|
|
177
|
+
|
|
178
|
+
return {"on_save": on_save, "on_load": on_load}
|
|
179
|
+
|
|
180
|
+
log_config: Mapping[str, Any] = field(
|
|
181
|
+
default_factory=lambda: {
|
|
182
|
+
"version": 1,
|
|
183
|
+
"incremental": False,
|
|
184
|
+
"disable_existing_loggers": False,
|
|
185
|
+
"formatters": {"standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}},
|
|
186
|
+
"handlers": {
|
|
187
|
+
"console": {
|
|
188
|
+
"class": "logging.StreamHandler",
|
|
189
|
+
"formatter": "standard",
|
|
190
|
+
"level": "INFO",
|
|
191
|
+
"stream": "ext://sys.stdout",
|
|
192
|
+
},
|
|
193
|
+
"file": {
|
|
194
|
+
"class": "logging.handlers.TimedRotatingFileHandler",
|
|
195
|
+
"formatter": "standard",
|
|
196
|
+
"filename": "Service.log",
|
|
197
|
+
"level": "DEBUG",
|
|
198
|
+
"when": "midnight",
|
|
199
|
+
"backupCount": 7,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
"root": {"level": "DEBUG", "handlers": ["console", "file"]},
|
|
203
|
+
},
|
|
204
|
+
metadata={
|
|
205
|
+
"description": (
|
|
206
|
+
"The values used to configure the application loggers. The dictionary must follow the standard logging"
|
|
207
|
+
"dictionary schema (https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)."
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
frequency: relativedelta = field(
|
|
213
|
+
default=relativedelta(seconds=-1),
|
|
214
|
+
metadata={
|
|
215
|
+
"description": (
|
|
216
|
+
"The time to wait before running the next iteration after completing an iteration. "
|
|
217
|
+
"If the total time is negative, then the service will only complete one iteration. "
|
|
218
|
+
"Keys here will be passed to the relativedelta constructor."
|
|
219
|
+
),
|
|
220
|
+
**relativedelta_funcs(),
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def save_to_file(self, file_path: Path) -> None:
|
|
225
|
+
"""Save the config to file."""
|
|
226
|
+
to_save: dict[str, dict[str, Any]] = json.loads(file_path.read_text()) if file_path.exists() else {}
|
|
227
|
+
|
|
228
|
+
fld: Field
|
|
229
|
+
for fld in fields(self):
|
|
230
|
+
default_value: Any = fld.default
|
|
231
|
+
if fld.default_factory is not MISSING:
|
|
232
|
+
default_value = fld.default_factory()
|
|
233
|
+
|
|
234
|
+
on_save: Callable[[Any], Any] = fld.metadata.get("on_save", lambda x: x)
|
|
235
|
+
to_save[fld.name] = {
|
|
236
|
+
"description": fld.metadata.get("description", ""),
|
|
237
|
+
"default": on_save(default_value),
|
|
238
|
+
"value": on_save(getattr(self, fld.name)),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
file_path.write_text(json.dumps(to_save, indent=4))
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def load_from_file(cls, file_path: Path) -> Self:
|
|
246
|
+
"""Load config from file.
|
|
247
|
+
|
|
248
|
+
:param file_path: The path to the config file.
|
|
249
|
+
:return: The config instance.
|
|
250
|
+
"""
|
|
251
|
+
if not file_path.exists():
|
|
252
|
+
instance: Self = cls()
|
|
253
|
+
instance.save_to_file(file_path)
|
|
254
|
+
return instance
|
|
255
|
+
|
|
256
|
+
loaded: dict[str, Any] = {}
|
|
257
|
+
with contextlib.suppress(OSError):
|
|
258
|
+
loaded = {k: v["value"] for k, v in json.loads(file_path.read_text()).items() if "value" in v}
|
|
259
|
+
|
|
260
|
+
kwargs: dict[str, Any] = {}
|
|
261
|
+
fld: Field
|
|
262
|
+
for fld in fields(cls):
|
|
263
|
+
if fld.name in loaded:
|
|
264
|
+
on_load: Callable[[Any], Any] = fld.metadata.get("on_load", lambda x: x)
|
|
265
|
+
kwargs[fld.name] = on_load(loaded[fld.name])
|
|
266
|
+
|
|
267
|
+
instance: Self = cls(**kwargs)
|
|
268
|
+
instance.save_to_file(file_path)
|
|
269
|
+
return instance
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Service[CONFIG: ServiceConfig](ABC):
|
|
273
|
+
"""Abstract base class for a Windows service."""
|
|
274
|
+
|
|
275
|
+
svc_name: str | None
|
|
276
|
+
"""The name of the service to give to Windows."""
|
|
277
|
+
svc_version: str | None = None
|
|
278
|
+
"""The version of the service to pass to *argparse*."""
|
|
279
|
+
svc_display_name: str | None = None
|
|
280
|
+
"""The display name of the service to give to Windows."""
|
|
281
|
+
svc_description: str | None = None
|
|
282
|
+
"""The description name of the service to give to Windows."""
|
|
283
|
+
svc_dependencies: Sequence[str] | None = None
|
|
284
|
+
"""The list of dependencies of the service to give to Windows."""
|
|
285
|
+
|
|
286
|
+
config_class: type[CONFIG]
|
|
287
|
+
|
|
288
|
+
def __init__(self, args: Sequence[str]) -> None:
|
|
289
|
+
"""Create an instance of the *PyService*.
|
|
290
|
+
|
|
291
|
+
:param args: The arguments that were passed to the *PyService*.
|
|
292
|
+
"""
|
|
293
|
+
if getattr(sys, "frozen", False): # pragma: no cover
|
|
294
|
+
exe_file: Path = Path(sys.executable).parent
|
|
295
|
+
os.chdir(exe_file)
|
|
296
|
+
|
|
297
|
+
self.args: Sequence[str] = args
|
|
298
|
+
|
|
299
|
+
self.config_file: Path = Path(f"./{self.svc_name}.json")
|
|
300
|
+
self.config: CONFIG = self.config_class.load_from_file(self.config_file)
|
|
301
|
+
|
|
302
|
+
log_config: dict[str, Any] = dict(self.config.log_config)
|
|
303
|
+
logging.config.dictConfig(log_config)
|
|
304
|
+
|
|
305
|
+
self._timer: Timer | None = None
|
|
306
|
+
self._stop_flag: Event = Event()
|
|
307
|
+
self._stop_flag.set()
|
|
308
|
+
|
|
309
|
+
self._executor: Executor | None = None
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def running(self) -> bool:
|
|
313
|
+
"""Return True if the service has started and is running."""
|
|
314
|
+
return not self._stop_flag.is_set()
|
|
315
|
+
|
|
316
|
+
def start(self) -> None:
|
|
317
|
+
"""Start the service."""
|
|
318
|
+
logger.info("Service starting . . .")
|
|
319
|
+
try:
|
|
320
|
+
if not self.on_start():
|
|
321
|
+
logger.warning("Service start was aborted.")
|
|
322
|
+
return
|
|
323
|
+
except Exception as e:
|
|
324
|
+
logger.critical("Unhandled exception during 'on_start':", exc_info=e)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
logger.info("Service started")
|
|
328
|
+
self._stop_flag.clear()
|
|
329
|
+
self._start_timer(0)
|
|
330
|
+
|
|
331
|
+
self._stop_flag.wait()
|
|
332
|
+
logger.info("Service stopping . . .")
|
|
333
|
+
self._stop_timer()
|
|
334
|
+
self._stop_executor()
|
|
335
|
+
try:
|
|
336
|
+
self.on_stop()
|
|
337
|
+
except Exception as e: # noqa: BLE001
|
|
338
|
+
logger.warning("Unhandled exception during 'on_stop':", exc_info=e)
|
|
339
|
+
|
|
340
|
+
logger.info("Service stopped")
|
|
341
|
+
|
|
342
|
+
def stop(self) -> None:
|
|
343
|
+
"""Stop the service."""
|
|
344
|
+
self._stop_flag.set()
|
|
345
|
+
|
|
346
|
+
def _run(self) -> None:
|
|
347
|
+
start_time: int = time.perf_counter_ns()
|
|
348
|
+
try:
|
|
349
|
+
logger.info("Service Running . . .")
|
|
350
|
+
self.on_run()
|
|
351
|
+
|
|
352
|
+
if self.running:
|
|
353
|
+
total_time: float = (time.perf_counter_ns() - start_time) / 1e9
|
|
354
|
+
self.on_run_success(total_time)
|
|
355
|
+
except Exception as exc: # noqa: BLE001
|
|
356
|
+
if self.running:
|
|
357
|
+
total_time: float = (time.perf_counter_ns() - start_time) / 1e9
|
|
358
|
+
self.on_run_failed(total_time, exc)
|
|
359
|
+
|
|
360
|
+
if self.running:
|
|
361
|
+
now: datetime = datetime.now() # noqa: DTZ005
|
|
362
|
+
next_start: datetime = now + self.config.frequency
|
|
363
|
+
if next_start >= now:
|
|
364
|
+
logger.info("Process will start at: %s", next_start.isoformat())
|
|
365
|
+
diff: timedelta = next_start - now
|
|
366
|
+
self._start_timer(diff.total_seconds())
|
|
367
|
+
else:
|
|
368
|
+
self.stop()
|
|
369
|
+
|
|
370
|
+
def _start_timer(self, interval: float) -> None:
|
|
371
|
+
if self.running:
|
|
372
|
+
if self._timer is not None: # pragma: no cover
|
|
373
|
+
self._timer.cancel()
|
|
374
|
+
self._timer = Timer(interval, self._run)
|
|
375
|
+
self._timer.name = "ProcessThread"
|
|
376
|
+
self._timer.daemon = True
|
|
377
|
+
self._timer.start()
|
|
378
|
+
elif self._timer is not None: # pragma: no cover
|
|
379
|
+
self._timer.cancel()
|
|
380
|
+
|
|
381
|
+
def _stop_timer(self) -> None:
|
|
382
|
+
if self._timer is not None:
|
|
383
|
+
try:
|
|
384
|
+
self._timer.cancel()
|
|
385
|
+
self._timer.join()
|
|
386
|
+
except RuntimeError: # pragma: no cover
|
|
387
|
+
pass
|
|
388
|
+
self._timer = None
|
|
389
|
+
|
|
390
|
+
def _stop_executor(self) -> None:
|
|
391
|
+
if self._executor is not None:
|
|
392
|
+
self._executor.shutdown(cancel_futures=True)
|
|
393
|
+
self._executor = None
|
|
394
|
+
|
|
395
|
+
@abstractmethod
|
|
396
|
+
def on_start(self) -> bool:
|
|
397
|
+
"""Service tasks to do when the service is starting.
|
|
398
|
+
|
|
399
|
+
:return: True if the service has successfully started, False otherwise.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
@abstractmethod
|
|
403
|
+
def on_run(self) -> None:
|
|
404
|
+
"""Service tasks to do when the timer elapses."""
|
|
405
|
+
|
|
406
|
+
def on_run_success(self, time_taken: float) -> None:
|
|
407
|
+
"""Service tasks to do on a successful run.
|
|
408
|
+
|
|
409
|
+
:param time_taken: The time in seconds that the run took.
|
|
410
|
+
"""
|
|
411
|
+
logger.info("Service finished in %.3f seconds", time_taken)
|
|
412
|
+
|
|
413
|
+
def on_run_failed(self, time_taken: float, exc: Exception) -> None:
|
|
414
|
+
"""Service tasks to do on a successful run.
|
|
415
|
+
|
|
416
|
+
:param time_taken: The time in seconds that the run took.
|
|
417
|
+
:param exc: The exception that was raised.
|
|
418
|
+
"""
|
|
419
|
+
logger.critical(
|
|
420
|
+
"An unhandled Exception caused the service to fail after %.3f seconds:",
|
|
421
|
+
time_taken,
|
|
422
|
+
exc_info=exc,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
@abstractmethod
|
|
426
|
+
def on_stop(self) -> None:
|
|
427
|
+
"""Service tasks to do when the service is stopping."""
|
|
428
|
+
|
|
429
|
+
@contextlib.contextmanager
|
|
430
|
+
def executor(
|
|
431
|
+
self,
|
|
432
|
+
max_workers: int | None = None,
|
|
433
|
+
thread_name_prefix: str = "Worker",
|
|
434
|
+
initializer: Callable[..., object] | None = None,
|
|
435
|
+
initargs: tuple = (),
|
|
436
|
+
) -> Generator[Executor | None]:
|
|
437
|
+
"""Context manager to create a *ThreadPoolExecutor*."""
|
|
438
|
+
try:
|
|
439
|
+
if not self.running:
|
|
440
|
+
yield None
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
self._executor = ThreadPoolExecutor(
|
|
444
|
+
max_workers=max_workers,
|
|
445
|
+
thread_name_prefix=thread_name_prefix,
|
|
446
|
+
initializer=initializer,
|
|
447
|
+
initargs=initargs,
|
|
448
|
+
)
|
|
449
|
+
yield self._executor
|
|
450
|
+
except (CancelledError, RuntimeError):
|
|
451
|
+
# This happens when the service is stopped before all Futures are finished so some
|
|
452
|
+
# are canceled. When you try to get the result of a canceled Future, it raises a
|
|
453
|
+
# CancelledError.
|
|
454
|
+
pass
|
|
455
|
+
finally:
|
|
456
|
+
self._executor = None
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def run(cls, *args: Any) -> CommandResult:
|
|
460
|
+
"""Run command line with arguments."""
|
|
461
|
+
exit_code: CommandResult = SUCCESS
|
|
462
|
+
try:
|
|
463
|
+
commands: Mapping[str, ServiceCommand] = cls.__gather_commands()
|
|
464
|
+
|
|
465
|
+
parser: ArgumentParser = cls.__create_command_parser(commands)
|
|
466
|
+
parsed_args: Namespace = parser.parse_args(args)
|
|
467
|
+
|
|
468
|
+
command_name: str | None = parsed_args.command
|
|
469
|
+
if command_name in commands:
|
|
470
|
+
command: ServiceCommand = commands[command_name]
|
|
471
|
+
exit_code = command(parsed_args)
|
|
472
|
+
else:
|
|
473
|
+
cls.__win_service(command_name, *args)
|
|
474
|
+
except ArgumentError as e:
|
|
475
|
+
# Raised when ArgumentParser fails to parse the arguments.
|
|
476
|
+
logger.exception("Argparse error:", exc_info=e)
|
|
477
|
+
exit_code = ARGUMENT_ERROR
|
|
478
|
+
except SystemExit:
|
|
479
|
+
# ArgParser exited, either error or help/version
|
|
480
|
+
exit_code = ARGPARSE_EXIT
|
|
481
|
+
except BaseException as e:
|
|
482
|
+
logger.exception("Unhandled exception:", exc_info=e)
|
|
483
|
+
exit_code = FAILURE
|
|
484
|
+
|
|
485
|
+
return exit_code
|
|
486
|
+
|
|
487
|
+
@classmethod
|
|
488
|
+
def handle_main(cls) -> NoReturn:
|
|
489
|
+
"""Handle main."""
|
|
490
|
+
args: list[str] = sys.argv[1:]
|
|
491
|
+
result: CommandResult = cls.run(*args)
|
|
492
|
+
sys.exit(result)
|
|
493
|
+
|
|
494
|
+
@classmethod
|
|
495
|
+
def __gather_commands(cls) -> Mapping[str, ServiceCommand]:
|
|
496
|
+
return {
|
|
497
|
+
name.removeprefix(COMMAND_PREFIX): method
|
|
498
|
+
for name, method in inspect.getmembers(cls, predicate=inspect.ismethod)
|
|
499
|
+
if name.startswith(COMMAND_PREFIX) and hasattr(method, "command")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
@classmethod
|
|
503
|
+
def __create_command_parser(cls, commands: Mapping[str, ServiceCommand]) -> ArgumentParser:
|
|
504
|
+
prog: str = f"{cls.svc_name}.exe" if getattr(sys, "frozen", False) else f"{cls.svc_name}.py"
|
|
505
|
+
parser: ArgumentParser = ArgumentParser(prog=prog, description=cls.svc_description, exit_on_error=False)
|
|
506
|
+
|
|
507
|
+
if cls.svc_version is not None:
|
|
508
|
+
parser.add_argument("-v", "--version", action="version", version=cls.svc_version)
|
|
509
|
+
|
|
510
|
+
# Re-Create win32serviceutil.HandleCommandLine commands so we can provide custom arguments
|
|
511
|
+
argument_group: _ArgumentGroup = parser.add_argument_group("options for 'install' and 'update' commands only")
|
|
512
|
+
argument_group.add_argument(
|
|
513
|
+
"-u",
|
|
514
|
+
"--username",
|
|
515
|
+
metavar="DOMAIN\\USERNAME",
|
|
516
|
+
help="the username the service is to run under",
|
|
517
|
+
)
|
|
518
|
+
argument_group.add_argument(
|
|
519
|
+
"-p",
|
|
520
|
+
"--password",
|
|
521
|
+
help="the password for the username",
|
|
522
|
+
)
|
|
523
|
+
argument_group.add_argument(
|
|
524
|
+
"-s",
|
|
525
|
+
"--startup",
|
|
526
|
+
choices=["manual", "auto", "disabled", "delayed"],
|
|
527
|
+
help="how the service starts, default = manual",
|
|
528
|
+
)
|
|
529
|
+
argument_group.add_argument(
|
|
530
|
+
"-i",
|
|
531
|
+
"--interactive",
|
|
532
|
+
action="store_true",
|
|
533
|
+
help="allow the service to interact with the desktop",
|
|
534
|
+
)
|
|
535
|
+
argument_group.add_argument(
|
|
536
|
+
"-ini",
|
|
537
|
+
"--perf_mon_ini",
|
|
538
|
+
type=Path,
|
|
539
|
+
metavar="FILE",
|
|
540
|
+
help="file to use for registering performance monitor data",
|
|
541
|
+
)
|
|
542
|
+
argument_group.add_argument(
|
|
543
|
+
"-dll",
|
|
544
|
+
"--perf_mon_dll",
|
|
545
|
+
type=Path,
|
|
546
|
+
metavar="FILE",
|
|
547
|
+
help="file to use when querying the service for performance data, default = perfmondata.dll",
|
|
548
|
+
)
|
|
549
|
+
argument_group.add_argument(
|
|
550
|
+
"-w",
|
|
551
|
+
"--wait",
|
|
552
|
+
type=int,
|
|
553
|
+
default=0,
|
|
554
|
+
metavar="SECONDS",
|
|
555
|
+
help=(
|
|
556
|
+
"wait for the service to actually start or stop. If you specify --wait with "
|
|
557
|
+
"the 'stop' option, the service and all dependent services will be stopped, "
|
|
558
|
+
"each waiting the specified period"
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
command_parser: _SubParsersAction = parser.add_subparsers(
|
|
563
|
+
dest="command",
|
|
564
|
+
metavar="command",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Win32 Defined Commands
|
|
568
|
+
command_parser.add_parser(
|
|
569
|
+
"install",
|
|
570
|
+
help="install the service",
|
|
571
|
+
)
|
|
572
|
+
command_parser.add_parser(
|
|
573
|
+
"remove",
|
|
574
|
+
help="remove the service",
|
|
575
|
+
)
|
|
576
|
+
command_parser.add_parser(
|
|
577
|
+
"update",
|
|
578
|
+
help="update the service",
|
|
579
|
+
)
|
|
580
|
+
command_parser.add_parser(
|
|
581
|
+
"stop",
|
|
582
|
+
help="stop the service",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
sub_parser: ArgumentParser
|
|
586
|
+
sub_parser = command_parser.add_parser(
|
|
587
|
+
"start",
|
|
588
|
+
help="start the service",
|
|
589
|
+
)
|
|
590
|
+
sub_parser.add_argument(
|
|
591
|
+
"arguments",
|
|
592
|
+
nargs=REMAINDER,
|
|
593
|
+
help="arguments passed to service constructor",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
sub_parser = command_parser.add_parser(
|
|
597
|
+
"restart",
|
|
598
|
+
help="stops, then starts the service",
|
|
599
|
+
)
|
|
600
|
+
sub_parser.add_argument(
|
|
601
|
+
"arguments",
|
|
602
|
+
nargs=REMAINDER,
|
|
603
|
+
help="arguments passed to service constructor",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
sub_parser = command_parser.add_parser(
|
|
607
|
+
"debug",
|
|
608
|
+
help="runs the service in debug mode",
|
|
609
|
+
)
|
|
610
|
+
sub_parser.add_argument(
|
|
611
|
+
"arguments",
|
|
612
|
+
nargs=REMAINDER,
|
|
613
|
+
help="arguments passed to service constructor",
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# User Defined Commands
|
|
617
|
+
command_name: str | None
|
|
618
|
+
command: ServiceCommand
|
|
619
|
+
for command_name, command in commands.items():
|
|
620
|
+
user_command_parser: ArgumentParser = command_parser.add_parser(command_name, **command.command.kwargs)
|
|
621
|
+
argument: Argument
|
|
622
|
+
for argument in command.command.args:
|
|
623
|
+
user_command_parser.add_argument(*argument.args, **argument.kwargs)
|
|
624
|
+
|
|
625
|
+
return parser
|
|
626
|
+
|
|
627
|
+
@classmethod
|
|
628
|
+
def __win_service(cls, command_name: str | None, *args: Any) -> None:
|
|
629
|
+
global WindowsService # noqa: PLW0603
|
|
630
|
+
import win32service # ty:ignore[unresolved-import] # noqa: PLC0415
|
|
631
|
+
import win32serviceutil # noqa: PLC0415
|
|
632
|
+
|
|
633
|
+
# noinspection PyRedeclaration
|
|
634
|
+
class WindowsService(win32serviceutil.ServiceFramework): # pragma: no cover
|
|
635
|
+
_svc_name_: str = cls.svc_name or cls.__name__
|
|
636
|
+
_svc_display_name_: str = cls.svc_display_name or cls.__name__
|
|
637
|
+
_svc_description_: str = cls.svc_description or ""
|
|
638
|
+
_svc_deps_: Sequence[str] | None = cls.svc_dependencies
|
|
639
|
+
|
|
640
|
+
def __init__(self, args: Sequence[str]) -> None:
|
|
641
|
+
super().__init__(args)
|
|
642
|
+
self.py_service = cls(args)
|
|
643
|
+
|
|
644
|
+
def SvcRun(self) -> None: # noqa: N802
|
|
645
|
+
self.ReportServiceStatus(win32service.SERVICE_START_PENDING)
|
|
646
|
+
if getattr(sys, "frozen", False):
|
|
647
|
+
exe_file: Path = Path(sys.executable).parent
|
|
648
|
+
os.chdir(exe_file)
|
|
649
|
+
|
|
650
|
+
self.ReportServiceStatus(win32service.SERVICE_RUNNING)
|
|
651
|
+
self.py_service.start()
|
|
652
|
+
self.ReportServiceStatus(win32service.SERVICE_STOPPED)
|
|
653
|
+
|
|
654
|
+
def SvcStop(self) -> None: # noqa: N802
|
|
655
|
+
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
|
|
656
|
+
self.py_service.stop()
|
|
657
|
+
|
|
658
|
+
if command_name is None:
|
|
659
|
+
import servicemanager # ty:ignore[unresolved-import] # noqa: PLC0415
|
|
660
|
+
|
|
661
|
+
servicemanager.Initialize()
|
|
662
|
+
servicemanager.PrepareToHostSingle(WindowsService)
|
|
663
|
+
servicemanager.StartServiceCtrlDispatcher()
|
|
664
|
+
else:
|
|
665
|
+
win32serviceutil.HandleCommandLine(WindowsService, argv=(sys.argv[0], *args))
|
|
666
|
+
|
|
667
|
+
@classmethod
|
|
668
|
+
@ServiceCommand.create(
|
|
669
|
+
Argument("arguments", nargs=REMAINDER, help="arguments passed to service constructor"),
|
|
670
|
+
help="runs the service without needing to be installed",
|
|
671
|
+
)
|
|
672
|
+
def command_start_local(cls, arguments: Sequence[str]) -> CommandResult:
|
|
673
|
+
"""Command to run the service locally instead of as a service."""
|
|
674
|
+
instance: Service = cls(arguments)
|
|
675
|
+
try:
|
|
676
|
+
thread: Thread = Thread(target=instance.start, name="StartThread")
|
|
677
|
+
thread.start()
|
|
678
|
+
while thread.is_alive():
|
|
679
|
+
time.sleep(1)
|
|
680
|
+
except BaseException as e: # noqa: BLE001 # pragma: no cover
|
|
681
|
+
# This mostly handles KeyboardInterrupt, but we want to report the exception anyway
|
|
682
|
+
instance.stop()
|
|
683
|
+
return str(e)
|
|
684
|
+
return SUCCESS
|
|
685
|
+
|
|
686
|
+
@classmethod
|
|
687
|
+
@ServiceCommand.create(
|
|
688
|
+
Argument("arguments", nargs=REMAINDER, help="arguments passed to service constructor"),
|
|
689
|
+
help="generate the batch files",
|
|
690
|
+
)
|
|
691
|
+
def command_gen_batch(cls, arguments: Sequence[str]) -> CommandResult:
|
|
692
|
+
"""Command to generate batch files for standard service operations."""
|
|
693
|
+
logger.info("Generating batch files . . .")
|
|
694
|
+
|
|
695
|
+
cls(arguments)
|
|
696
|
+
|
|
697
|
+
name: str
|
|
698
|
+
contents: str
|
|
699
|
+
for name, contents in {
|
|
700
|
+
"Install": f'cd /d "%~dp0"\n{cls.svc_name} install\ntimeout 5\n',
|
|
701
|
+
"Remove": f'cd /d "%~dp0"\n{cls.svc_name} remove\ntimeout 5\n',
|
|
702
|
+
"Start": f'cd /d "%~dp0"\n{cls.svc_name} start\ntimeout 5\n',
|
|
703
|
+
"Stop": f'cd /d "%~dp0"\n{cls.svc_name} stop\ntimeout 5\n',
|
|
704
|
+
"Restart": f'cd /d "%~dp0"\n{cls.svc_name} restart\ntimeout 5\n',
|
|
705
|
+
}.items():
|
|
706
|
+
file: Path = Path(f"./{cls.svc_name}_{name}.bat")
|
|
707
|
+
file.write_text(contents)
|
|
708
|
+
return SUCCESS
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyWinService
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: Ryan Smith <7897914+Mimer29or40@users.noreply.github.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/Mimer29or40/PyService
|
|
7
|
+
Keywords: KEYWORDS
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Framework :: Flake8
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Framework :: tox
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: pywin32>=311
|
|
23
|
+
Requires-Dist: python-dateutil>=2.9.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: tox>=4.36.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
27
|
+
Requires-Dist: pyinstaller>=6.19.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pyWinService/__init__.py,sha256=ZBObLYYC1nXC_qWPbuaysnbEdatbp_xXvPmwu6jKwOI,26028
|
|
2
|
+
pywinservice-2.0.1.dist-info/licenses/LICENSE,sha256=EwZhDTq1iyzJP3b16yyRHYyGcAQB0wxW--LsC016vaE,1089
|
|
3
|
+
pywinservice-2.0.1.dist-info/METADATA,sha256=jiAjAqYhomnwohS3eK4I2X30hhkulJJihZBKWJSDQx8,1084
|
|
4
|
+
pywinservice-2.0.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
5
|
+
pywinservice-2.0.1.dist-info/top_level.txt,sha256=i7IUYdIR4D7rhaGHkowvvyY69n0Q3zpgbF2xKkcENXc,13
|
|
6
|
+
pywinservice-2.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ryan Smith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyWinService
|