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.
@@ -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,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ [console_scripts]
2
+ yyds-notify = yyds_notify_os.cli:main
3
+ yyds-notify-os = yyds_notify_os.cli:main
@@ -0,0 +1 @@
1
+ yyds_notify_os