yyds-notify-os 0.2.0__tar.gz
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.
- yyds_notify_os-0.2.0/PKG-INFO +151 -0
- yyds_notify_os-0.2.0/README.md +122 -0
- yyds_notify_os-0.2.0/pyproject.toml +3 -0
- yyds_notify_os-0.2.0/setup.cfg +4 -0
- yyds_notify_os-0.2.0/setup.py +50 -0
- yyds_notify_os-0.2.0/tests/test_notify.py +195 -0
- yyds_notify_os-0.2.0/yyds_notify_os/__init__.py +25 -0
- yyds_notify_os-0.2.0/yyds_notify_os/__version__.py +9 -0
- yyds_notify_os-0.2.0/yyds_notify_os/cli.py +48 -0
- yyds_notify_os-0.2.0/yyds_notify_os/core.py +399 -0
- yyds_notify_os-0.2.0/yyds_notify_os.egg-info/PKG-INFO +151 -0
- yyds_notify_os-0.2.0/yyds_notify_os.egg-info/SOURCES.txt +13 -0
- yyds_notify_os-0.2.0/yyds_notify_os.egg-info/dependency_links.txt +1 -0
- yyds_notify_os-0.2.0/yyds_notify_os.egg-info/entry_points.txt +3 -0
- yyds_notify_os-0.2.0/yyds_notify_os.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yyds-notify-os
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A high-performance, lightweight, and easy-to-use cross-platform desktop notification library for Python.
|
|
5
|
+
Home-page: https://github.com/yyds-fast/yyds-notify-os
|
|
6
|
+
Author: yyds-fast
|
|
7
|
+
Author-email: yyds.fast@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: author-email
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
Dynamic: summary
|
|
29
|
+
|
|
30
|
+
# yyds-notify-os
|
|
31
|
+
|
|
32
|
+
A high-performance, lightweight, and easy-to-use cross-platform desktop notification library for Python.
|
|
33
|
+
|
|
34
|
+
[δΈζθ―΄ζ (Chinese README)](README_CN.md)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## π‘ Key Features
|
|
39
|
+
|
|
40
|
+
* **Zero External Dependencies**: Implemented strictly using the Python standard library. It interacts with native OS notification tools via subprocesses.
|
|
41
|
+
* **Microsecond-Level Startup**: Highly optimized imports (under 1ms), leaving virtually zero performance footprint.
|
|
42
|
+
* **Non-blocking by Default**: Notifications are dispatched asynchronously in background daemon threads, keeping your application GUI or CLI completely lag-free.
|
|
43
|
+
* **Notification Replacement/Updating (`replace_id`)**: Update an existing notification card in-place (ideal for progress bars or dynamic alerts) without cluttering the Action Center.
|
|
44
|
+
* **Modern Windows Customizations**: Uses Microsoft's modern `ToastGeneric` template to support custom application icons (`icon`) and mapped system sounds (`sound`) or mute configurations.
|
|
45
|
+
* **Path Auto-Resolution**: Automatically converts relative icon paths to absolute paths to prevent subprocess path resolution failures.
|
|
46
|
+
* **Robust Fail-Safe Fallbacks**: Under headless environments (e.g. SSH sessions) or when graphical notifications fail, it gracefully falls back to console stderr output without crashing.
|
|
47
|
+
* **Command Line Interface (CLI)**: Out-of-the-box `yyds-notify` / `yyds-notify-os` commands for shell script integrations.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## π Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install -U yyds-notify-os
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or install from source in editable mode for local development:
|
|
58
|
+
```bash
|
|
59
|
+
pip install -e .
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## π» Python API Usage
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import yyds_notify_os as notify
|
|
68
|
+
import time
|
|
69
|
+
|
|
70
|
+
# 1. Simple Usage (Non-blocking by default)
|
|
71
|
+
notify.send("Task Complete", "Your compilation has finished successfully!")
|
|
72
|
+
|
|
73
|
+
# 2. Dynamic Update/Replacement (Same ID updates the same card in-place)
|
|
74
|
+
for i in range(1, 6):
|
|
75
|
+
notify.send("Downloading", f"Progress: {i*20}%", replace_id="download_task_1")
|
|
76
|
+
time.sleep(1)
|
|
77
|
+
|
|
78
|
+
# 3. Synchronous Blocking Call (Returns True/False based on execution success)
|
|
79
|
+
success = notify.send("Server Alert", "CPU temperature is too high!", urgency="critical", block=True)
|
|
80
|
+
|
|
81
|
+
# 4. Custom Cross-Platform Configuration
|
|
82
|
+
notify.send(
|
|
83
|
+
title="Meeting Reminder",
|
|
84
|
+
message="Technical review starts at 2:00 PM",
|
|
85
|
+
subtitle="Sprint Sync", # Supported on macOS and Linux-zenity
|
|
86
|
+
icon="assets/bell.png", # Automatically converted to absolute path (Windows/Linux)
|
|
87
|
+
sound="sms", # Windows SMS tone mapping, default sound on macOS
|
|
88
|
+
urgency="normal", # Linux urgency levels: 'low', 'normal', 'critical'
|
|
89
|
+
timeout=5, # Display timeout in seconds (Linux)
|
|
90
|
+
app_name="yyds-notify" # Custom application sender name (Windows/Linux)
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Windows Built-in Sound Mappings (`sound` parameter)
|
|
95
|
+
* `"default"`: Default system sound
|
|
96
|
+
* `"im"`: Instant message sound
|
|
97
|
+
* `"mail"`: Email notification sound
|
|
98
|
+
* `"reminder"`: Calendar reminder sound
|
|
99
|
+
* `"sms"`: SMS/Text message sound
|
|
100
|
+
* `"alarm"`: Looping alarm sound
|
|
101
|
+
* `"call"`: Looping ringtone sound
|
|
102
|
+
|
|
103
|
+
### API Aliases
|
|
104
|
+
The following functions are identical aliases for convenience:
|
|
105
|
+
* `yyds_notify_os.notify(...)`
|
|
106
|
+
* `yyds_notify_os.send(...)`
|
|
107
|
+
* `yyds_notify_os.show(...)`
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## π οΈ CLI Usage
|
|
112
|
+
|
|
113
|
+
Once installed, send system notifications directly from your shell:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# Basic notification
|
|
117
|
+
yyds-notify "Notification" "Your build is ready!"
|
|
118
|
+
|
|
119
|
+
# Specify custom sender name
|
|
120
|
+
yyds-notify "Alert" "High memory usage detected!" -a "SystemMonitor"
|
|
121
|
+
|
|
122
|
+
# Dynamic notification updates using replace-id
|
|
123
|
+
yyds-notify "Build Status" "Compiling Module A..." -r "build_job_12"
|
|
124
|
+
yyds-notify "Build Status" "Compiling Module B..." -r "build_job_12"
|
|
125
|
+
|
|
126
|
+
# Complex call with sound and critical urgency
|
|
127
|
+
yyds-notify "Error" "Deployment failed!" -s "CI Pipeline" -u critical --sound
|
|
128
|
+
|
|
129
|
+
# View full help menu
|
|
130
|
+
yyds-notify --help
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## π‘οΈ Technical Implementation Details
|
|
136
|
+
|
|
137
|
+
1. **Windows**:
|
|
138
|
+
- Uses PowerShell to interface with the Windows Runtime (WinRT) `ToastGeneric` visual template.
|
|
139
|
+
- All string arguments (title, message, icon path) are passed via **process environment variables** to completely avoid shell injection and encoding/truncation issues (e.g. UTF-8/GBK encoding clashes).
|
|
140
|
+
- Maps `replace_id` to the `ToastNotification.Tag` property for in-place card updates.
|
|
141
|
+
- Gracefully attempts to initialize the ToastNotifier with your custom `app_name`. If it fails (due to unregistered AppUserModelId on older Windows releases), it falls back to a guaranteed registered PowerShell AppID.
|
|
142
|
+
- If WinRT initialization fails, it falls back to the classic balloon tip (`System.Windows.Forms.NotifyIcon`).
|
|
143
|
+
|
|
144
|
+
2. **macOS**:
|
|
145
|
+
- Executes AppleScript (`osascript`) with display notification instructions.
|
|
146
|
+
- Implements robust escaping for double quotes and backslashes to eliminate script execution failures.
|
|
147
|
+
|
|
148
|
+
3. **Linux**:
|
|
149
|
+
- Detects `DISPLAY` and `WAYLAND_DISPLAY` environments.
|
|
150
|
+
- If GUI is present, uses `notify-send` with a progressive fallback array (peels off unsupported options like `-r` or `-a` step-by-step if the local `notify-send` version is outdated). Falls back to `zenity --notification` if `notify-send` is completely absent.
|
|
151
|
+
- If headless (no GUI), automatically prints notification details to standard error (`sys.stderr`) to prevent crashes.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# yyds-notify-os
|
|
2
|
+
|
|
3
|
+
A high-performance, lightweight, and easy-to-use cross-platform desktop notification library for Python.
|
|
4
|
+
|
|
5
|
+
[δΈζθ―΄ζ (Chinese README)](README_CN.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## π‘ Key Features
|
|
10
|
+
|
|
11
|
+
* **Zero External Dependencies**: Implemented strictly using the Python standard library. It interacts with native OS notification tools via subprocesses.
|
|
12
|
+
* **Microsecond-Level Startup**: Highly optimized imports (under 1ms), leaving virtually zero performance footprint.
|
|
13
|
+
* **Non-blocking by Default**: Notifications are dispatched asynchronously in background daemon threads, keeping your application GUI or CLI completely lag-free.
|
|
14
|
+
* **Notification Replacement/Updating (`replace_id`)**: Update an existing notification card in-place (ideal for progress bars or dynamic alerts) without cluttering the Action Center.
|
|
15
|
+
* **Modern Windows Customizations**: Uses Microsoft's modern `ToastGeneric` template to support custom application icons (`icon`) and mapped system sounds (`sound`) or mute configurations.
|
|
16
|
+
* **Path Auto-Resolution**: Automatically converts relative icon paths to absolute paths to prevent subprocess path resolution failures.
|
|
17
|
+
* **Robust Fail-Safe Fallbacks**: Under headless environments (e.g. SSH sessions) or when graphical notifications fail, it gracefully falls back to console stderr output without crashing.
|
|
18
|
+
* **Command Line Interface (CLI)**: Out-of-the-box `yyds-notify` / `yyds-notify-os` commands for shell script integrations.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## π Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -U yyds-notify-os
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install from source in editable mode for local development:
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## π» Python API Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import yyds_notify_os as notify
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
# 1. Simple Usage (Non-blocking by default)
|
|
42
|
+
notify.send("Task Complete", "Your compilation has finished successfully!")
|
|
43
|
+
|
|
44
|
+
# 2. Dynamic Update/Replacement (Same ID updates the same card in-place)
|
|
45
|
+
for i in range(1, 6):
|
|
46
|
+
notify.send("Downloading", f"Progress: {i*20}%", replace_id="download_task_1")
|
|
47
|
+
time.sleep(1)
|
|
48
|
+
|
|
49
|
+
# 3. Synchronous Blocking Call (Returns True/False based on execution success)
|
|
50
|
+
success = notify.send("Server Alert", "CPU temperature is too high!", urgency="critical", block=True)
|
|
51
|
+
|
|
52
|
+
# 4. Custom Cross-Platform Configuration
|
|
53
|
+
notify.send(
|
|
54
|
+
title="Meeting Reminder",
|
|
55
|
+
message="Technical review starts at 2:00 PM",
|
|
56
|
+
subtitle="Sprint Sync", # Supported on macOS and Linux-zenity
|
|
57
|
+
icon="assets/bell.png", # Automatically converted to absolute path (Windows/Linux)
|
|
58
|
+
sound="sms", # Windows SMS tone mapping, default sound on macOS
|
|
59
|
+
urgency="normal", # Linux urgency levels: 'low', 'normal', 'critical'
|
|
60
|
+
timeout=5, # Display timeout in seconds (Linux)
|
|
61
|
+
app_name="yyds-notify" # Custom application sender name (Windows/Linux)
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Windows Built-in Sound Mappings (`sound` parameter)
|
|
66
|
+
* `"default"`: Default system sound
|
|
67
|
+
* `"im"`: Instant message sound
|
|
68
|
+
* `"mail"`: Email notification sound
|
|
69
|
+
* `"reminder"`: Calendar reminder sound
|
|
70
|
+
* `"sms"`: SMS/Text message sound
|
|
71
|
+
* `"alarm"`: Looping alarm sound
|
|
72
|
+
* `"call"`: Looping ringtone sound
|
|
73
|
+
|
|
74
|
+
### API Aliases
|
|
75
|
+
The following functions are identical aliases for convenience:
|
|
76
|
+
* `yyds_notify_os.notify(...)`
|
|
77
|
+
* `yyds_notify_os.send(...)`
|
|
78
|
+
* `yyds_notify_os.show(...)`
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## π οΈ CLI Usage
|
|
83
|
+
|
|
84
|
+
Once installed, send system notifications directly from your shell:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Basic notification
|
|
88
|
+
yyds-notify "Notification" "Your build is ready!"
|
|
89
|
+
|
|
90
|
+
# Specify custom sender name
|
|
91
|
+
yyds-notify "Alert" "High memory usage detected!" -a "SystemMonitor"
|
|
92
|
+
|
|
93
|
+
# Dynamic notification updates using replace-id
|
|
94
|
+
yyds-notify "Build Status" "Compiling Module A..." -r "build_job_12"
|
|
95
|
+
yyds-notify "Build Status" "Compiling Module B..." -r "build_job_12"
|
|
96
|
+
|
|
97
|
+
# Complex call with sound and critical urgency
|
|
98
|
+
yyds-notify "Error" "Deployment failed!" -s "CI Pipeline" -u critical --sound
|
|
99
|
+
|
|
100
|
+
# View full help menu
|
|
101
|
+
yyds-notify --help
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## π‘οΈ Technical Implementation Details
|
|
107
|
+
|
|
108
|
+
1. **Windows**:
|
|
109
|
+
- Uses PowerShell to interface with the Windows Runtime (WinRT) `ToastGeneric` visual template.
|
|
110
|
+
- All string arguments (title, message, icon path) are passed via **process environment variables** to completely avoid shell injection and encoding/truncation issues (e.g. UTF-8/GBK encoding clashes).
|
|
111
|
+
- Maps `replace_id` to the `ToastNotification.Tag` property for in-place card updates.
|
|
112
|
+
- Gracefully attempts to initialize the ToastNotifier with your custom `app_name`. If it fails (due to unregistered AppUserModelId on older Windows releases), it falls back to a guaranteed registered PowerShell AppID.
|
|
113
|
+
- If WinRT initialization fails, it falls back to the classic balloon tip (`System.Windows.Forms.NotifyIcon`).
|
|
114
|
+
|
|
115
|
+
2. **macOS**:
|
|
116
|
+
- Executes AppleScript (`osascript`) with display notification instructions.
|
|
117
|
+
- Implements robust escaping for double quotes and backslashes to eliminate script execution failures.
|
|
118
|
+
|
|
119
|
+
3. **Linux**:
|
|
120
|
+
- Detects `DISPLAY` and `WAYLAND_DISPLAY` environments.
|
|
121
|
+
- If GUI is present, uses `notify-send` with a progressive fallback array (peels off unsupported options like `-r` or `-a` step-by-step if the local `notify-send` version is outdated). Falls back to `zenity --notification` if `notify-send` is completely absent.
|
|
122
|
+
- If headless (no GUI), automatically prints notification details to standard error (`sys.stderr`) to prevent crashes.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from setuptools import setup, find_packages
|
|
5
|
+
from codecs import open
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
about = {}
|
|
9
|
+
here = os.path.abspath(os.path.dirname(__file__))
|
|
10
|
+
with open(os.path.join(here, "yyds_notify_os", "__version__.py"), "r", "utf-8") as f:
|
|
11
|
+
exec(f.read(), about)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
with open("README.md", "r", encoding="utf-8") as fh:
|
|
15
|
+
long_description = fh.read()
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
long_description = about["__description__"]
|
|
18
|
+
|
|
19
|
+
setup(
|
|
20
|
+
name=about["__title__"],
|
|
21
|
+
version=about["__version__"],
|
|
22
|
+
author=about["__author__"],
|
|
23
|
+
author_email=about["__author_email__"],
|
|
24
|
+
description=about["__description__"],
|
|
25
|
+
long_description=long_description,
|
|
26
|
+
long_description_content_type="text/markdown",
|
|
27
|
+
url=about["__url__"],
|
|
28
|
+
license=about.get("__license__", "MIT"),
|
|
29
|
+
packages=find_packages(),
|
|
30
|
+
include_package_data=True,
|
|
31
|
+
python_requires='>=3.7',
|
|
32
|
+
classifiers=[
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.7",
|
|
35
|
+
"Programming Language :: Python :: 3.8",
|
|
36
|
+
"Programming Language :: Python :: 3.9",
|
|
37
|
+
"Programming Language :: Python :: 3.10",
|
|
38
|
+
"Programming Language :: Python :: 3.11",
|
|
39
|
+
"Programming Language :: Python :: 3.12",
|
|
40
|
+
"Operating System :: OS Independent",
|
|
41
|
+
"License :: OSI Approved :: MIT License",
|
|
42
|
+
],
|
|
43
|
+
install_requires=[],
|
|
44
|
+
entry_points={
|
|
45
|
+
"console_scripts": [
|
|
46
|
+
"yyds-notify = yyds_notify_os.cli:main",
|
|
47
|
+
"yyds-notify-os = yyds_notify_os.cli:main",
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import platform
|
|
8
|
+
import base64
|
|
9
|
+
|
|
10
|
+
# Add the parent directory to Python path to import yyds_notify_os
|
|
11
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
|
12
|
+
|
|
13
|
+
from yyds_notify_os.core import notify, _escape_applescript, _send_notification_sync, _resolve_icon_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestNotifyOS(unittest.TestCase):
|
|
17
|
+
|
|
18
|
+
def test_escape_applescript(self):
|
|
19
|
+
self.assertEqual(_escape_applescript('hello "world"'), 'hello \\"world\\"')
|
|
20
|
+
self.assertEqual(_escape_applescript('back\\slash'), 'back\\\\slash')
|
|
21
|
+
self.assertEqual(_escape_applescript(''), '')
|
|
22
|
+
self.assertEqual(_escape_applescript(None), '')
|
|
23
|
+
|
|
24
|
+
@patch("os.path.exists")
|
|
25
|
+
def test_resolve_icon_path(self, mock_exists):
|
|
26
|
+
# 1. File exists locally
|
|
27
|
+
mock_exists.return_value = True
|
|
28
|
+
resolved = _resolve_icon_path("myicon.png")
|
|
29
|
+
self.assertTrue(os.path.isabs(resolved))
|
|
30
|
+
|
|
31
|
+
# 2. File does not exist, not Linux -> returns as-is
|
|
32
|
+
mock_exists.return_value = False
|
|
33
|
+
with patch("platform.system", return_value="Windows"):
|
|
34
|
+
resolved = _resolve_icon_path("non_existent_file.png")
|
|
35
|
+
self.assertEqual(resolved, "non_existent_file.png")
|
|
36
|
+
|
|
37
|
+
# 3. Linux system icon name (no slashes) -> returns as-is
|
|
38
|
+
with patch("platform.system", return_value="Linux"):
|
|
39
|
+
resolved = _resolve_icon_path("dialog-information")
|
|
40
|
+
self.assertEqual(resolved, "dialog-information")
|
|
41
|
+
|
|
42
|
+
def test_notify_invalid_inputs(self):
|
|
43
|
+
with self.assertRaises(ValueError):
|
|
44
|
+
notify("", "message")
|
|
45
|
+
with self.assertRaises(ValueError):
|
|
46
|
+
notify("title", "")
|
|
47
|
+
with self.assertRaises(ValueError):
|
|
48
|
+
notify(None, "message")
|
|
49
|
+
|
|
50
|
+
@patch("platform.system")
|
|
51
|
+
@patch("subprocess.run")
|
|
52
|
+
def test_linux_notify_send_success(self, mock_run, mock_system):
|
|
53
|
+
mock_system.return_value = "Linux"
|
|
54
|
+
mock_run.side_effect = [
|
|
55
|
+
MagicMock(returncode=0), # which notify-send
|
|
56
|
+
MagicMock(returncode=0), # notify-send with -r
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
with patch.dict(os.environ, {"DISPLAY": ":0"}):
|
|
60
|
+
res = _send_notification_sync(
|
|
61
|
+
"MyTitle", "MyMessage", urgency="critical", timeout=3, app_name="TestApp", replace_id=123
|
|
62
|
+
)
|
|
63
|
+
self.assertTrue(res)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(mock_run.call_count, 2)
|
|
66
|
+
called_args = mock_run.call_args_list[1][0][0]
|
|
67
|
+
self.assertIn("notify-send", called_args)
|
|
68
|
+
self.assertIn("MyTitle", called_args)
|
|
69
|
+
self.assertIn("MyMessage", called_args)
|
|
70
|
+
self.assertIn("-t", called_args)
|
|
71
|
+
self.assertIn("3000", called_args)
|
|
72
|
+
self.assertIn("-u", called_args)
|
|
73
|
+
self.assertIn("critical", called_args)
|
|
74
|
+
self.assertIn("-a", called_args)
|
|
75
|
+
self.assertIn("TestApp", called_args)
|
|
76
|
+
self.assertIn("-r", called_args)
|
|
77
|
+
self.assertIn("123", called_args)
|
|
78
|
+
|
|
79
|
+
@patch("platform.system")
|
|
80
|
+
@patch("subprocess.run")
|
|
81
|
+
def test_linux_notify_send_replace_id_fallback(self, mock_run, mock_system):
|
|
82
|
+
mock_system.return_value = "Linux"
|
|
83
|
+
mock_run.side_effect = [
|
|
84
|
+
MagicMock(returncode=0), # which notify-send
|
|
85
|
+
MagicMock(returncode=1), # notify-send with -r (fails)
|
|
86
|
+
MagicMock(returncode=0), # fallback notify-send without -r (succeeds)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
with patch.dict(os.environ, {"DISPLAY": ":0"}):
|
|
90
|
+
res = _send_notification_sync("MyTitle", "MyMessage", replace_id=456, fallback_to_print=False)
|
|
91
|
+
self.assertTrue(res)
|
|
92
|
+
|
|
93
|
+
self.assertEqual(mock_run.call_count, 3)
|
|
94
|
+
called_args_r = mock_run.call_args_list[1][0][0]
|
|
95
|
+
called_args_fallback = mock_run.call_args_list[2][0][0]
|
|
96
|
+
self.assertIn("-r", called_args_r)
|
|
97
|
+
self.assertNotIn("-r", called_args_fallback)
|
|
98
|
+
|
|
99
|
+
@patch("platform.system")
|
|
100
|
+
@patch("subprocess.run")
|
|
101
|
+
def test_linux_zenity_fallback(self, mock_run, mock_system):
|
|
102
|
+
mock_system.return_value = "Linux"
|
|
103
|
+
mock_run.side_effect = [
|
|
104
|
+
Exception("not found"), # which notify-send
|
|
105
|
+
MagicMock(returncode=0), # which zenity
|
|
106
|
+
MagicMock(returncode=0), # zenity command
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
with patch.dict(os.environ, {"DISPLAY": ":0"}):
|
|
110
|
+
res = _send_notification_sync("MyTitle", "MyMessage", fallback_to_print=False)
|
|
111
|
+
self.assertTrue(res)
|
|
112
|
+
|
|
113
|
+
self.assertEqual(mock_run.call_count, 3)
|
|
114
|
+
called_args = mock_run.call_args_list[2][0][0]
|
|
115
|
+
self.assertIn("zenity", called_args)
|
|
116
|
+
self.assertIn("--notification", called_args)
|
|
117
|
+
self.assertIn("--text=<b>MyTitle</b>\nMyMessage", called_args)
|
|
118
|
+
|
|
119
|
+
@patch("platform.system")
|
|
120
|
+
@patch("subprocess.run")
|
|
121
|
+
@patch("sys.stderr.write")
|
|
122
|
+
def test_headless_fallback_to_print(self, mock_stderr, mock_run, mock_system):
|
|
123
|
+
mock_system.return_value = "Linux"
|
|
124
|
+
|
|
125
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
126
|
+
res = _send_notification_sync("Title", "Message", fallback_to_print=True)
|
|
127
|
+
self.assertFalse(res)
|
|
128
|
+
mock_run.assert_not_called()
|
|
129
|
+
mock_stderr.assert_called_once()
|
|
130
|
+
written_str = mock_stderr.call_args[0][0]
|
|
131
|
+
self.assertIn("[Notification] Title: Message", written_str)
|
|
132
|
+
|
|
133
|
+
@patch("platform.system")
|
|
134
|
+
@patch("subprocess.run")
|
|
135
|
+
def test_mac_osascript(self, mock_run, mock_system):
|
|
136
|
+
mock_system.return_value = "Darwin"
|
|
137
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
138
|
+
|
|
139
|
+
res = _send_notification_sync(
|
|
140
|
+
"Hello", 'World "Quotes"', subtitle="Sub", sound=True
|
|
141
|
+
)
|
|
142
|
+
self.assertTrue(res)
|
|
143
|
+
mock_run.assert_called_once()
|
|
144
|
+
called_args = mock_run.call_args[0][0]
|
|
145
|
+
self.assertEqual(called_args[0], "osascript")
|
|
146
|
+
self.assertEqual(called_args[1], "-e")
|
|
147
|
+
expected_script = 'display notification "World \\"Quotes\\"" with title "Hello" subtitle "Sub" sound name "Tink"'
|
|
148
|
+
self.assertEqual(called_args[2], expected_script)
|
|
149
|
+
|
|
150
|
+
@patch("platform.system")
|
|
151
|
+
@patch("subprocess.run")
|
|
152
|
+
@patch("os.path.exists", return_value=True)
|
|
153
|
+
def test_windows_powershell(self, mock_exists, mock_run, mock_system):
|
|
154
|
+
mock_system.return_value = "Windows"
|
|
155
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
156
|
+
|
|
157
|
+
res = _send_notification_sync("WinTitle", "WinMessage", sound="sms", icon="logo.png", replace_id="task_12")
|
|
158
|
+
self.assertTrue(res)
|
|
159
|
+
|
|
160
|
+
mock_run.assert_called_once()
|
|
161
|
+
called_args = mock_run.call_args[0][0]
|
|
162
|
+
self.assertEqual(called_args[0], "powershell")
|
|
163
|
+
self.assertEqual(called_args[3], "-EncodedCommand")
|
|
164
|
+
|
|
165
|
+
encoded_data = called_args[4]
|
|
166
|
+
decoded_script = base64.b64decode(encoded_data).decode("utf-16le")
|
|
167
|
+
# Check ToastGeneric template check & replace tag & app_name notifier
|
|
168
|
+
self.assertIn("ToastGeneric", decoded_script)
|
|
169
|
+
self.assertIn("appLogoOverride", decoded_script)
|
|
170
|
+
self.assertIn("$toast.Tag = $tag", decoded_script)
|
|
171
|
+
self.assertIn("CreateToastNotifier($app_name)", decoded_script)
|
|
172
|
+
|
|
173
|
+
env = mock_run.call_args[1]["env"]
|
|
174
|
+
self.assertEqual(env["NOTIFY_TITLE"], "WinTitle")
|
|
175
|
+
self.assertEqual(env["NOTIFY_MESSAGE"], "WinMessage")
|
|
176
|
+
self.assertEqual(env["NOTIFY_APP_NAME"], "yyds-notify")
|
|
177
|
+
self.assertEqual(env["NOTIFY_SOUND_SRC"], "ms-winsoundevent:Notification.SMS")
|
|
178
|
+
self.assertEqual(env["NOTIFY_SILENT"], "False")
|
|
179
|
+
self.assertEqual(env["NOTIFY_TAG"], "task_12")
|
|
180
|
+
self.assertTrue(os.path.isabs(env["NOTIFY_ICON_PATH"]))
|
|
181
|
+
|
|
182
|
+
@patch("threading.Thread")
|
|
183
|
+
def test_async_non_blocking(self, mock_thread):
|
|
184
|
+
mock_thread_instance = MagicMock()
|
|
185
|
+
mock_thread.return_value = mock_thread_instance
|
|
186
|
+
|
|
187
|
+
res = notify("Title", "Message", block=False)
|
|
188
|
+
self.assertTrue(res)
|
|
189
|
+
mock_thread.assert_called_once()
|
|
190
|
+
self.assertTrue(mock_thread.call_args[1]["daemon"])
|
|
191
|
+
mock_thread_instance.start.assert_called_once()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
unittest.main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from yyds_notify_os.core import notify, show, send
|
|
4
|
+
from yyds_notify_os.__version__ import (
|
|
5
|
+
__title__,
|
|
6
|
+
__description__,
|
|
7
|
+
__version__,
|
|
8
|
+
__author__,
|
|
9
|
+
__author_email__,
|
|
10
|
+
__url__,
|
|
11
|
+
__license__,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"notify",
|
|
16
|
+
"show",
|
|
17
|
+
"send",
|
|
18
|
+
"__title__",
|
|
19
|
+
"__description__",
|
|
20
|
+
"__version__",
|
|
21
|
+
"__author__",
|
|
22
|
+
"__author_email__",
|
|
23
|
+
"__url__",
|
|
24
|
+
"__license__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# yyds-notify-os
|
|
2
|
+
|
|
3
|
+
__title__ = "yyds-notify-os"
|
|
4
|
+
__description__ = "A high-performance, lightweight, and easy-to-use cross-platform desktop notification library for Python."
|
|
5
|
+
__version__ = "0.2.0"
|
|
6
|
+
__author__ = "yyds-fast"
|
|
7
|
+
__author_email__ = "yyds.fast@gmail.com"
|
|
8
|
+
__url__ = "https://github.com/yyds-fast/yyds-notify-os"
|
|
9
|
+
__license__ = "MIT"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from yyds_notify_os.core import notify
|
|
6
|
+
from yyds_notify_os.__version__ import __version__, __title__
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description="A beautiful, cross-platform command line notification tool.",
|
|
11
|
+
prog="yyds-notify"
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument("title", help="Title of the notification")
|
|
14
|
+
parser.add_argument("message", help="Body/message content of the notification")
|
|
15
|
+
parser.add_argument("-s", "--subtitle", help="Subtitle (supported on macOS/some Linux setups)")
|
|
16
|
+
parser.add_argument("-i", "--icon", help="Icon name or image path (Linux/Windows support)")
|
|
17
|
+
parser.add_argument("-u", "--urgency", choices=["low", "normal", "critical"], default="normal",
|
|
18
|
+
help="Urgency level (Linux only, default: normal)")
|
|
19
|
+
parser.add_argument("-t", "--timeout", type=int, default=5,
|
|
20
|
+
help="Notification display timeout in seconds (Linux only, default: 5)")
|
|
21
|
+
parser.add_argument("--sound", action="store_true", help="Play a notification sound")
|
|
22
|
+
parser.add_argument("-a", "--app-name", default="yyds-notify", help="Application name (default: yyds-notify)")
|
|
23
|
+
parser.add_argument("-r", "--replace-id", help="Notification replacement ID to update an existing popup (Linux/Windows only)")
|
|
24
|
+
parser.add_argument("--async", dest="block", action="store_false", default=True,
|
|
25
|
+
help="Send notification asynchronously (non-blocking) in background")
|
|
26
|
+
parser.add_argument("-v", "--version", action="version", version=f"{__title__} {__version__}")
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
success = notify(
|
|
31
|
+
title=args.title,
|
|
32
|
+
message=args.message,
|
|
33
|
+
subtitle=args.subtitle,
|
|
34
|
+
icon=args.icon,
|
|
35
|
+
urgency=args.urgency,
|
|
36
|
+
timeout=args.timeout,
|
|
37
|
+
sound=args.sound,
|
|
38
|
+
app_name=args.app_name,
|
|
39
|
+
replace_id=args.replace_id,
|
|
40
|
+
block=args.block,
|
|
41
|
+
fallback_to_print=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Return exit code 0 on success, 1 on failure
|
|
45
|
+
sys.exit(0 if success else 1)
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import logging
|
|
9
|
+
import base64
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("yyds_notify_os")
|
|
12
|
+
|
|
13
|
+
# Setup default logger formatting to be simple and clean
|
|
14
|
+
if not logger.handlers:
|
|
15
|
+
handler = logging.StreamHandler()
|
|
16
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
17
|
+
handler.setFormatter(formatter)
|
|
18
|
+
logger.addHandler(handler)
|
|
19
|
+
logger.setLevel(logging.WARNING)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Windows sound events mapping
|
|
23
|
+
WIN_SOUND_MAP = {
|
|
24
|
+
"default": "ms-winsoundevent:Notification.Default",
|
|
25
|
+
"im": "ms-winsoundevent:Notification.IM",
|
|
26
|
+
"mail": "ms-winsoundevent:Notification.Mail",
|
|
27
|
+
"reminder": "ms-winsoundevent:Notification.Reminder",
|
|
28
|
+
"sms": "ms-winsoundevent:Notification.SMS",
|
|
29
|
+
"alarm": "ms-winsoundevent:Notification.Looping.Alarm",
|
|
30
|
+
"call": "ms-winsoundevent:Notification.Looping.Call",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _escape_applescript(text: str) -> str:
|
|
35
|
+
"""Escapes string inputs for use in AppleScript double quotes."""
|
|
36
|
+
if not text:
|
|
37
|
+
return ""
|
|
38
|
+
return text.replace('\\', '\\\\').replace('"', '\\"')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_icon_path(icon: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Resolves icon path to an absolute path if it points to a local file.
|
|
44
|
+
If it is a Linux system icon name, it is returned as-is.
|
|
45
|
+
"""
|
|
46
|
+
if not icon:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Check if local file exists
|
|
50
|
+
if os.path.exists(icon):
|
|
51
|
+
return os.path.abspath(icon)
|
|
52
|
+
|
|
53
|
+
# For Linux, standard icon names are valid (e.g. "dialog-information")
|
|
54
|
+
if platform.system() == "Linux" and "/" not in icon and "\\" not in icon:
|
|
55
|
+
return icon
|
|
56
|
+
|
|
57
|
+
return icon
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _send_notification_sync(
|
|
61
|
+
title: str,
|
|
62
|
+
message: str,
|
|
63
|
+
subtitle: str = None,
|
|
64
|
+
icon: str = None,
|
|
65
|
+
urgency: str = "normal",
|
|
66
|
+
timeout: int = 5,
|
|
67
|
+
sound = False,
|
|
68
|
+
app_name: str = "yyds-notify",
|
|
69
|
+
replace_id = None,
|
|
70
|
+
fallback_to_print: bool = True,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Synchronously triggers a system notification based on the current platform.
|
|
74
|
+
Returns True if notification succeeded, False if it failed/fell back.
|
|
75
|
+
"""
|
|
76
|
+
system = platform.system()
|
|
77
|
+
|
|
78
|
+
# Validate urgency
|
|
79
|
+
urgency = urgency.lower()
|
|
80
|
+
if urgency not in ("low", "normal", "critical"):
|
|
81
|
+
urgency = "normal"
|
|
82
|
+
|
|
83
|
+
# Resolve icon path
|
|
84
|
+
icon_path = _resolve_icon_path(icon)
|
|
85
|
+
|
|
86
|
+
success = False
|
|
87
|
+
|
|
88
|
+
# ----------------------------------------------------
|
|
89
|
+
# WINDOWS IMPLEMENTATION
|
|
90
|
+
# ----------------------------------------------------
|
|
91
|
+
if system == "Windows":
|
|
92
|
+
# We run PowerShell using environment variables to transfer strings safely
|
|
93
|
+
# and Base64 EncodedCommand to bypass any complex console escaping problems.
|
|
94
|
+
app_id = f"{{{os.environ.get('COMPUTERNAME', 'Python-Notify')}}}\\WindowsPowerShell\\v1.0\\powershell.exe"
|
|
95
|
+
|
|
96
|
+
# Map sound to Windows sound events
|
|
97
|
+
sound_src = ""
|
|
98
|
+
silent = "True"
|
|
99
|
+
if sound:
|
|
100
|
+
silent = "False"
|
|
101
|
+
if isinstance(sound, str) and sound.lower() in WIN_SOUND_MAP:
|
|
102
|
+
sound_src = WIN_SOUND_MAP[sound.lower()]
|
|
103
|
+
else:
|
|
104
|
+
sound_src = WIN_SOUND_MAP["default"]
|
|
105
|
+
|
|
106
|
+
ps_script = """
|
|
107
|
+
$title = $env:NOTIFY_TITLE
|
|
108
|
+
$message = $env:NOTIFY_MESSAGE
|
|
109
|
+
$app_name = $env:NOTIFY_APP_NAME
|
|
110
|
+
$app_id = $env:NOTIFY_APP_ID
|
|
111
|
+
$icon_path = $env:NOTIFY_ICON_PATH
|
|
112
|
+
$sound_src = $env:NOTIFY_SOUND_SRC
|
|
113
|
+
$silent = $env:NOTIFY_SILENT
|
|
114
|
+
$tag = $env:NOTIFY_TAG
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
[void][System.Reflection.Assembly]::LoadWithPartialName('Windows.UI.Notifications')
|
|
118
|
+
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
|
|
119
|
+
|
|
120
|
+
# Use ToastGeneric template for rich visual capabilities (supports custom icons)
|
|
121
|
+
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastGeneric)
|
|
122
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
123
|
+
$xml.LoadXml($template.GetXml())
|
|
124
|
+
|
|
125
|
+
# Set Title & Message
|
|
126
|
+
$texts = $xml.GetElementsByTagName("text")
|
|
127
|
+
if ($texts.Count -gt 0) { $texts.Item(0).AppendChild($xml.CreateTextNode($title)) > $null }
|
|
128
|
+
if ($texts.Count -gt 1) { $texts.Item(1).AppendChild($xml.CreateTextNode($message)) > $null }
|
|
129
|
+
|
|
130
|
+
# Set Icon (appLogoOverride)
|
|
131
|
+
if ($icon_path) {
|
|
132
|
+
$binding = $xml.GetElementsByTagName("binding").Item(0)
|
|
133
|
+
$imageNode = $xml.CreateElement("image")
|
|
134
|
+
$imageNode.SetAttribute("placement", "appLogoOverride")
|
|
135
|
+
$imageNode.SetAttribute("src", $icon_path)
|
|
136
|
+
$binding.AppendChild($imageNode) > $null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Configure Sound
|
|
140
|
+
$toastNode = $xml.GetElementsByTagName("toast").Item(0)
|
|
141
|
+
$audioNode = $xml.CreateElement("audio")
|
|
142
|
+
if ($silent -eq "True") {
|
|
143
|
+
$audioNode.SetAttribute("silent", "true")
|
|
144
|
+
} else {
|
|
145
|
+
$audioNode.SetAttribute("silent", "false")
|
|
146
|
+
if ($sound_src) {
|
|
147
|
+
$audioNode.SetAttribute("src", $sound_src)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
$toastNode.AppendChild($audioNode) > $null
|
|
151
|
+
|
|
152
|
+
# Instantiate Toast
|
|
153
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
154
|
+
|
|
155
|
+
# Set Tag for replacing/updating notifications
|
|
156
|
+
if ($tag) {
|
|
157
|
+
$toast.Tag = $tag
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Try to show notification using custom app name first
|
|
161
|
+
try {
|
|
162
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app_name).Show($toast)
|
|
163
|
+
} catch {
|
|
164
|
+
# Fallback to the registered PowerShell App ID if the custom name causes issues
|
|
165
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app_id).Show($toast)
|
|
166
|
+
}
|
|
167
|
+
exit 0
|
|
168
|
+
} catch {
|
|
169
|
+
# Fallback to legacy System.Windows.Forms.NotifyIcon
|
|
170
|
+
try {
|
|
171
|
+
[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')
|
|
172
|
+
$notification = New-Object System.Windows.Forms.NotifyIcon
|
|
173
|
+
$notification.Icon = [System.Drawing.SystemIcons]::Information
|
|
174
|
+
$notification.BalloonTipTitle = $title
|
|
175
|
+
$notification.BalloonTipText = $message
|
|
176
|
+
$notification.Visible = $True
|
|
177
|
+
$notification.ShowBalloonTip(5000)
|
|
178
|
+
Start-Sleep -Seconds 1
|
|
179
|
+
$notification.Dispose()
|
|
180
|
+
exit 0
|
|
181
|
+
} catch {
|
|
182
|
+
exit 1
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
"""
|
|
186
|
+
# Prepare environment variables
|
|
187
|
+
env = os.environ.copy()
|
|
188
|
+
env["NOTIFY_TITLE"] = title
|
|
189
|
+
env["NOTIFY_MESSAGE"] = message
|
|
190
|
+
env["NOTIFY_APP_NAME"] = app_name
|
|
191
|
+
env["NOTIFY_APP_ID"] = app_id
|
|
192
|
+
env["NOTIFY_ICON_PATH"] = icon_path if icon_path else ""
|
|
193
|
+
env["NOTIFY_SOUND_SRC"] = sound_src
|
|
194
|
+
env["NOTIFY_SILENT"] = silent
|
|
195
|
+
env["NOTIFY_TAG"] = str(replace_id) if replace_id is not None else ""
|
|
196
|
+
|
|
197
|
+
# Encode script in UTF-16LE and Base64 as PowerShell requires
|
|
198
|
+
encoded_script = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
res = subprocess.run(
|
|
202
|
+
["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded_script],
|
|
203
|
+
env=env,
|
|
204
|
+
capture_output=True,
|
|
205
|
+
creationflags=0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, # CREATE_NO_WINDOW
|
|
206
|
+
)
|
|
207
|
+
success = (res.returncode == 0)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.debug(f"PowerShell notification failed: {e}")
|
|
210
|
+
success = False
|
|
211
|
+
|
|
212
|
+
# ----------------------------------------------------
|
|
213
|
+
# MACOS IMPLEMENTATION
|
|
214
|
+
# ----------------------------------------------------
|
|
215
|
+
elif system == "Darwin":
|
|
216
|
+
# AppleScript execution
|
|
217
|
+
esc_title = _escape_applescript(title)
|
|
218
|
+
esc_msg = _escape_applescript(message)
|
|
219
|
+
esc_sub = _escape_applescript(subtitle) if subtitle else None
|
|
220
|
+
|
|
221
|
+
script = f'display notification "{esc_msg}" with title "{esc_title}"'
|
|
222
|
+
if esc_sub:
|
|
223
|
+
script += f' subtitle "{esc_sub}"'
|
|
224
|
+
|
|
225
|
+
if sound:
|
|
226
|
+
sound_name = sound if isinstance(sound, str) else "Tink"
|
|
227
|
+
script += f' sound name "{sound_name}"'
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
res = subprocess.run(["osascript", "-e", script], capture_output=True)
|
|
231
|
+
success = (res.returncode == 0)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.debug(f"AppleScript notification failed: {e}")
|
|
234
|
+
success = False
|
|
235
|
+
|
|
236
|
+
# ----------------------------------------------------
|
|
237
|
+
# LINUX & OTHER UNIX IMPLEMENTATION
|
|
238
|
+
# ----------------------------------------------------
|
|
239
|
+
else:
|
|
240
|
+
# Detect headless (no DISPLAY or WAYLAND_DISPLAY environment variables)
|
|
241
|
+
is_headless = not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
242
|
+
|
|
243
|
+
if not is_headless:
|
|
244
|
+
# Check availability of notify-send
|
|
245
|
+
has_notify_send = False
|
|
246
|
+
try:
|
|
247
|
+
subprocess.run(["which", "notify-send"], capture_output=True, check=True)
|
|
248
|
+
has_notify_send = True
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
if has_notify_send:
|
|
253
|
+
# Progressive fallbacks for notify-send command to maximize compatibility
|
|
254
|
+
cmds_to_try = []
|
|
255
|
+
|
|
256
|
+
# 1. Full command (with replace_id, app_name, icon, timeout, urgency)
|
|
257
|
+
cmd_full = ["notify-send", title, message, "-t", str(timeout * 1000), "-u", urgency]
|
|
258
|
+
if app_name:
|
|
259
|
+
cmd_full.extend(["-a", app_name])
|
|
260
|
+
if icon_path:
|
|
261
|
+
cmd_full.extend(["-i", icon_path])
|
|
262
|
+
if replace_id is not None:
|
|
263
|
+
cmd_full.extend(["-r", str(replace_id)])
|
|
264
|
+
cmds_to_try.append(cmd_full)
|
|
265
|
+
|
|
266
|
+
# 2. Try without replace_id (-r option is not supported on older versions)
|
|
267
|
+
if replace_id is not None:
|
|
268
|
+
cmd_no_r = ["notify-send", title, message, "-t", str(timeout * 1000), "-u", urgency]
|
|
269
|
+
if app_name:
|
|
270
|
+
cmd_no_r.extend(["-a", app_name])
|
|
271
|
+
if icon_path:
|
|
272
|
+
cmd_no_r.extend(["-i", icon_path])
|
|
273
|
+
cmds_to_try.append(cmd_no_r)
|
|
274
|
+
|
|
275
|
+
# 3. Try without app_name (-a option might not be supported in some environments)
|
|
276
|
+
cmd_no_a = ["notify-send", title, message, "-t", str(timeout * 1000), "-u", urgency]
|
|
277
|
+
if icon_path:
|
|
278
|
+
cmd_no_a.extend(["-i", icon_path])
|
|
279
|
+
cmds_to_try.append(cmd_no_a)
|
|
280
|
+
|
|
281
|
+
# 4. Minimal command (just title and message)
|
|
282
|
+
cmd_minimal = ["notify-send", title, message]
|
|
283
|
+
cmds_to_try.append(cmd_minimal)
|
|
284
|
+
|
|
285
|
+
# Execute in sequence until one succeeds
|
|
286
|
+
for current_cmd in cmds_to_try:
|
|
287
|
+
try:
|
|
288
|
+
res = subprocess.run(current_cmd, capture_output=True)
|
|
289
|
+
if res.returncode == 0:
|
|
290
|
+
success = True
|
|
291
|
+
break
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.debug(f"Command {' '.join(current_cmd)} failed: {e}")
|
|
294
|
+
|
|
295
|
+
# Try zenity fallback if notify-send failed or wasn't available
|
|
296
|
+
if not success:
|
|
297
|
+
try:
|
|
298
|
+
subprocess.run(["which", "zenity"], capture_output=True, check=True)
|
|
299
|
+
|
|
300
|
+
zenity_text = f"<b>{title}</b>\n{message}"
|
|
301
|
+
if subtitle:
|
|
302
|
+
zenity_text = f"<b>{title}</b>\n<i>{subtitle}</i>\n{message}"
|
|
303
|
+
|
|
304
|
+
cmd = ["zenity", "--notification", f"--text={zenity_text}"]
|
|
305
|
+
if icon_path:
|
|
306
|
+
cmd.append(f"--window-icon={icon_path}")
|
|
307
|
+
|
|
308
|
+
res = subprocess.run(cmd, capture_output=True)
|
|
309
|
+
success = (res.returncode == 0)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.debug(f"zenity command failed: {e}")
|
|
312
|
+
success = False
|
|
313
|
+
|
|
314
|
+
else:
|
|
315
|
+
logger.debug("System is running in headless mode. GUI notifications skipped.")
|
|
316
|
+
success = False
|
|
317
|
+
|
|
318
|
+
# ----------------------------------------------------
|
|
319
|
+
# DEGRADED FALLBACK
|
|
320
|
+
# ----------------------------------------------------
|
|
321
|
+
if not success and fallback_to_print:
|
|
322
|
+
# Fallback print to terminal or stderr
|
|
323
|
+
fallback_msg = f"[Notification] {title}"
|
|
324
|
+
if subtitle:
|
|
325
|
+
fallback_msg += f" ({subtitle})"
|
|
326
|
+
fallback_msg += f": {message}"
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
sys.stderr.write(fallback_msg + "\n")
|
|
330
|
+
sys.stderr.flush()
|
|
331
|
+
except Exception:
|
|
332
|
+
print(fallback_msg)
|
|
333
|
+
|
|
334
|
+
logger.info(fallback_msg)
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
return success
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def notify(
|
|
341
|
+
title: str,
|
|
342
|
+
message: str,
|
|
343
|
+
subtitle: str = None,
|
|
344
|
+
icon: str = None,
|
|
345
|
+
urgency: str = "normal",
|
|
346
|
+
timeout: int = 5,
|
|
347
|
+
sound = False,
|
|
348
|
+
app_name: str = "yyds-notify",
|
|
349
|
+
replace_id = None,
|
|
350
|
+
block: bool = False,
|
|
351
|
+
fallback_to_print: bool = True,
|
|
352
|
+
) -> bool:
|
|
353
|
+
"""
|
|
354
|
+
Sends a cross-platform desktop notification.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
title (str): Title of the notification.
|
|
358
|
+
message (str): Body text of the notification.
|
|
359
|
+
subtitle (str, optional): Subtitle of the notification (macOS/zenity fallback support).
|
|
360
|
+
icon (str, optional): Icon name or path (Linux/Windows/zenity support).
|
|
361
|
+
urgency (str): Urgency level: 'low', 'normal', 'critical' (Linux support).
|
|
362
|
+
timeout (int): Expiration timeout in seconds (Linux support). Defaults to 5.
|
|
363
|
+
sound (bool or str): If True, plays default system sound (macOS/Windows).
|
|
364
|
+
On macOS, can also be a string name of the system sound.
|
|
365
|
+
On Windows, can be: 'default', 'im', 'mail', 'reminder', 'sms', 'alarm', 'call'.
|
|
366
|
+
app_name (str): The application name triggering the notification (Linux/Windows support).
|
|
367
|
+
replace_id (str or int, optional): A unique ID. If specified, sending a new notification
|
|
368
|
+
with the same ID will replace/update the existing one.
|
|
369
|
+
block (bool): If True, block calling thread until notification command finishes.
|
|
370
|
+
If False (default), dispatch asynchronously on a daemon thread.
|
|
371
|
+
fallback_to_print (bool): If True (default), prints notification details to terminal
|
|
372
|
+
when OS notification fails or under headless environment.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
bool: True if synchronous invocation succeeded or background thread started,
|
|
376
|
+
False otherwise.
|
|
377
|
+
"""
|
|
378
|
+
if not title:
|
|
379
|
+
raise ValueError("Notification 'title' cannot be empty.")
|
|
380
|
+
if not message:
|
|
381
|
+
raise ValueError("Notification 'message' cannot be empty.")
|
|
382
|
+
|
|
383
|
+
args = (title, message, subtitle, icon, urgency, timeout, sound, app_name, replace_id, fallback_to_print)
|
|
384
|
+
|
|
385
|
+
if block:
|
|
386
|
+
return _send_notification_sync(*args)
|
|
387
|
+
else:
|
|
388
|
+
# Launch asynchronously in a daemon thread so it is completely non-blocking
|
|
389
|
+
thread = threading.Thread(
|
|
390
|
+
target=lambda: _send_notification_sync(*args),
|
|
391
|
+
daemon=True
|
|
392
|
+
)
|
|
393
|
+
thread.start()
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# Convenience aliases
|
|
398
|
+
show = notify
|
|
399
|
+
send = notify
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yyds-notify-os
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A high-performance, lightweight, and easy-to-use cross-platform desktop notification library for Python.
|
|
5
|
+
Home-page: https://github.com/yyds-fast/yyds-notify-os
|
|
6
|
+
Author: yyds-fast
|
|
7
|
+
Author-email: yyds.fast@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Dynamic: author
|
|
21
|
+
Dynamic: author-email
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: requires-python
|
|
28
|
+
Dynamic: summary
|
|
29
|
+
|
|
30
|
+
# yyds-notify-os
|
|
31
|
+
|
|
32
|
+
A high-performance, lightweight, and easy-to-use cross-platform desktop notification library for Python.
|
|
33
|
+
|
|
34
|
+
[δΈζθ―΄ζ (Chinese README)](README_CN.md)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## π‘ Key Features
|
|
39
|
+
|
|
40
|
+
* **Zero External Dependencies**: Implemented strictly using the Python standard library. It interacts with native OS notification tools via subprocesses.
|
|
41
|
+
* **Microsecond-Level Startup**: Highly optimized imports (under 1ms), leaving virtually zero performance footprint.
|
|
42
|
+
* **Non-blocking by Default**: Notifications are dispatched asynchronously in background daemon threads, keeping your application GUI or CLI completely lag-free.
|
|
43
|
+
* **Notification Replacement/Updating (`replace_id`)**: Update an existing notification card in-place (ideal for progress bars or dynamic alerts) without cluttering the Action Center.
|
|
44
|
+
* **Modern Windows Customizations**: Uses Microsoft's modern `ToastGeneric` template to support custom application icons (`icon`) and mapped system sounds (`sound`) or mute configurations.
|
|
45
|
+
* **Path Auto-Resolution**: Automatically converts relative icon paths to absolute paths to prevent subprocess path resolution failures.
|
|
46
|
+
* **Robust Fail-Safe Fallbacks**: Under headless environments (e.g. SSH sessions) or when graphical notifications fail, it gracefully falls back to console stderr output without crashing.
|
|
47
|
+
* **Command Line Interface (CLI)**: Out-of-the-box `yyds-notify` / `yyds-notify-os` commands for shell script integrations.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## π Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install -U yyds-notify-os
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or install from source in editable mode for local development:
|
|
58
|
+
```bash
|
|
59
|
+
pip install -e .
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## π» Python API Usage
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import yyds_notify_os as notify
|
|
68
|
+
import time
|
|
69
|
+
|
|
70
|
+
# 1. Simple Usage (Non-blocking by default)
|
|
71
|
+
notify.send("Task Complete", "Your compilation has finished successfully!")
|
|
72
|
+
|
|
73
|
+
# 2. Dynamic Update/Replacement (Same ID updates the same card in-place)
|
|
74
|
+
for i in range(1, 6):
|
|
75
|
+
notify.send("Downloading", f"Progress: {i*20}%", replace_id="download_task_1")
|
|
76
|
+
time.sleep(1)
|
|
77
|
+
|
|
78
|
+
# 3. Synchronous Blocking Call (Returns True/False based on execution success)
|
|
79
|
+
success = notify.send("Server Alert", "CPU temperature is too high!", urgency="critical", block=True)
|
|
80
|
+
|
|
81
|
+
# 4. Custom Cross-Platform Configuration
|
|
82
|
+
notify.send(
|
|
83
|
+
title="Meeting Reminder",
|
|
84
|
+
message="Technical review starts at 2:00 PM",
|
|
85
|
+
subtitle="Sprint Sync", # Supported on macOS and Linux-zenity
|
|
86
|
+
icon="assets/bell.png", # Automatically converted to absolute path (Windows/Linux)
|
|
87
|
+
sound="sms", # Windows SMS tone mapping, default sound on macOS
|
|
88
|
+
urgency="normal", # Linux urgency levels: 'low', 'normal', 'critical'
|
|
89
|
+
timeout=5, # Display timeout in seconds (Linux)
|
|
90
|
+
app_name="yyds-notify" # Custom application sender name (Windows/Linux)
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Windows Built-in Sound Mappings (`sound` parameter)
|
|
95
|
+
* `"default"`: Default system sound
|
|
96
|
+
* `"im"`: Instant message sound
|
|
97
|
+
* `"mail"`: Email notification sound
|
|
98
|
+
* `"reminder"`: Calendar reminder sound
|
|
99
|
+
* `"sms"`: SMS/Text message sound
|
|
100
|
+
* `"alarm"`: Looping alarm sound
|
|
101
|
+
* `"call"`: Looping ringtone sound
|
|
102
|
+
|
|
103
|
+
### API Aliases
|
|
104
|
+
The following functions are identical aliases for convenience:
|
|
105
|
+
* `yyds_notify_os.notify(...)`
|
|
106
|
+
* `yyds_notify_os.send(...)`
|
|
107
|
+
* `yyds_notify_os.show(...)`
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## π οΈ CLI Usage
|
|
112
|
+
|
|
113
|
+
Once installed, send system notifications directly from your shell:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# Basic notification
|
|
117
|
+
yyds-notify "Notification" "Your build is ready!"
|
|
118
|
+
|
|
119
|
+
# Specify custom sender name
|
|
120
|
+
yyds-notify "Alert" "High memory usage detected!" -a "SystemMonitor"
|
|
121
|
+
|
|
122
|
+
# Dynamic notification updates using replace-id
|
|
123
|
+
yyds-notify "Build Status" "Compiling Module A..." -r "build_job_12"
|
|
124
|
+
yyds-notify "Build Status" "Compiling Module B..." -r "build_job_12"
|
|
125
|
+
|
|
126
|
+
# Complex call with sound and critical urgency
|
|
127
|
+
yyds-notify "Error" "Deployment failed!" -s "CI Pipeline" -u critical --sound
|
|
128
|
+
|
|
129
|
+
# View full help menu
|
|
130
|
+
yyds-notify --help
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## π‘οΈ Technical Implementation Details
|
|
136
|
+
|
|
137
|
+
1. **Windows**:
|
|
138
|
+
- Uses PowerShell to interface with the Windows Runtime (WinRT) `ToastGeneric` visual template.
|
|
139
|
+
- All string arguments (title, message, icon path) are passed via **process environment variables** to completely avoid shell injection and encoding/truncation issues (e.g. UTF-8/GBK encoding clashes).
|
|
140
|
+
- Maps `replace_id` to the `ToastNotification.Tag` property for in-place card updates.
|
|
141
|
+
- Gracefully attempts to initialize the ToastNotifier with your custom `app_name`. If it fails (due to unregistered AppUserModelId on older Windows releases), it falls back to a guaranteed registered PowerShell AppID.
|
|
142
|
+
- If WinRT initialization fails, it falls back to the classic balloon tip (`System.Windows.Forms.NotifyIcon`).
|
|
143
|
+
|
|
144
|
+
2. **macOS**:
|
|
145
|
+
- Executes AppleScript (`osascript`) with display notification instructions.
|
|
146
|
+
- Implements robust escaping for double quotes and backslashes to eliminate script execution failures.
|
|
147
|
+
|
|
148
|
+
3. **Linux**:
|
|
149
|
+
- Detects `DISPLAY` and `WAYLAND_DISPLAY` environments.
|
|
150
|
+
- If GUI is present, uses `notify-send` with a progressive fallback array (peels off unsupported options like `-r` or `-a` step-by-step if the local `notify-send` version is outdated). Falls back to `zenity --notification` if `notify-send` is completely absent.
|
|
151
|
+
- If headless (no GUI), automatically prints notification details to standard error (`sys.stderr`) to prevent crashes.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
tests/test_notify.py
|
|
5
|
+
yyds_notify_os/__init__.py
|
|
6
|
+
yyds_notify_os/__version__.py
|
|
7
|
+
yyds_notify_os/cli.py
|
|
8
|
+
yyds_notify_os/core.py
|
|
9
|
+
yyds_notify_os.egg-info/PKG-INFO
|
|
10
|
+
yyds_notify_os.egg-info/SOURCES.txt
|
|
11
|
+
yyds_notify_os.egg-info/dependency_links.txt
|
|
12
|
+
yyds_notify_os.egg-info/entry_points.txt
|
|
13
|
+
yyds_notify_os.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yyds_notify_os
|