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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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