lupaxa-slackit 0.1.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.
- lupaxa_slackit-0.1.0/.gitignore +23 -0
- lupaxa_slackit-0.1.0/LICENCE +21 -0
- lupaxa_slackit-0.1.0/PKG-INFO +219 -0
- lupaxa_slackit-0.1.0/README-PYPI.md +158 -0
- lupaxa_slackit-0.1.0/README.md +84 -0
- lupaxa_slackit-0.1.0/docs/README.md +23 -0
- lupaxa_slackit-0.1.0/pyproject.toml +122 -0
- lupaxa_slackit-0.1.0/src/lupaxa/slackit/__init__.py +19 -0
- lupaxa_slackit-0.1.0/src/lupaxa/slackit/__main__.py +8 -0
- lupaxa_slackit-0.1.0/src/lupaxa/slackit/cli.py +143 -0
- lupaxa_slackit-0.1.0/src/lupaxa/slackit/client.py +132 -0
- lupaxa_slackit-0.1.0/src/lupaxa/slackit/config.py +145 -0
- lupaxa_slackit-0.1.0/src/lupaxa/slackit/version.py +10 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# OS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Cursor
|
|
5
|
+
.superpowers/
|
|
6
|
+
cursor-docs
|
|
7
|
+
|
|
8
|
+
# Make
|
|
9
|
+
.makefiles/
|
|
10
|
+
|
|
11
|
+
# MkDocs
|
|
12
|
+
site/
|
|
13
|
+
|
|
14
|
+
# Python
|
|
15
|
+
.venv/
|
|
16
|
+
.coverage
|
|
17
|
+
__pycache__/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
dist/
|
|
22
|
+
build/
|
|
23
|
+
*.egg-info/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) The Lupaxa Project
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lupaxa-slackit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send Slack messages through an incoming webhook.
|
|
5
|
+
Project-URL: Homepage, https://slackit.thelupaxaproject.org/
|
|
6
|
+
Project-URL: Documentation, https://slackit.thelupaxaproject.org/
|
|
7
|
+
Project-URL: Repository, https://github.com/lupaxa-notifications-toolbox/slackit
|
|
8
|
+
Project-URL: Issues, https://github.com/lupaxa-notifications-toolbox/slackit/issues
|
|
9
|
+
Author: The Lupaxa Project
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) The Lupaxa Project
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENCE
|
|
32
|
+
Keywords: cli,notifications,slack,webhook
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Environment :: Console
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: Intended Audience :: System Administrators
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Operating System :: OS Independent
|
|
39
|
+
Classifier: Programming Language :: Python :: 3
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
42
|
+
Classifier: Topic :: Communications :: Chat
|
|
43
|
+
Classifier: Topic :: Utilities
|
|
44
|
+
Requires-Python: >=3.13
|
|
45
|
+
Requires-Dist: pyyaml>=6
|
|
46
|
+
Requires-Dist: requests>=2.32
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: bump-my-version>=1.2.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: hatch>=1.12.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: mkdocs-material==9.7.7; extra == 'dev'
|
|
51
|
+
Requires-Dist: mkdocs==1.6.1; extra == 'dev'
|
|
52
|
+
Requires-Dist: mypy>=1.12.0; extra == 'dev'
|
|
53
|
+
Requires-Dist: pip-audit>=2.7.0; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
55
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
57
|
+
Provides-Extra: test
|
|
58
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'test'
|
|
59
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
60
|
+
Description-Content-Type: text/markdown
|
|
61
|
+
|
|
62
|
+
<!-- markdownlint-disable -->
|
|
63
|
+
<p align="center">
|
|
64
|
+
<a href="https://github.com/lupaxa-notifications-toolbox">
|
|
65
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/notifications-toolbox/readme-logo.png" alt="Project Logo" width="256"/><br/>
|
|
66
|
+
</a>
|
|
67
|
+
</p>
|
|
68
|
+
<h3 align="center">
|
|
69
|
+
The Lupaxa Notifications Toolbox<br />
|
|
70
|
+
Part of The Lupaxa Project
|
|
71
|
+
</h3>
|
|
72
|
+
|
|
73
|
+
<br />
|
|
74
|
+
|
|
75
|
+
# lupaxa-slackit
|
|
76
|
+
|
|
77
|
+
Send Slack messages through an incoming webhook.
|
|
78
|
+
|
|
79
|
+
## Features
|
|
80
|
+
|
|
81
|
+
- Post message text, one attachment, or Block Kit blocks
|
|
82
|
+
- Set a username, channel, and icon emoji on each client
|
|
83
|
+
- Keep several webhook profiles in `$HOME/.slackit.yml` and select one with `--profile`
|
|
84
|
+
- Override any profile field with a CLI flag
|
|
85
|
+
- Turn escaped `\n` sequences in message text into real line breaks
|
|
86
|
+
- Reject webhook URLs that do not start with `https://hooks.slack.com/services/`
|
|
87
|
+
- Reject webhook URLs that answer `HEAD` with a redirect
|
|
88
|
+
- Use the `Slackit` library class or the `slackit` command
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
### From PyPI
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install lupaxa-slackit
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### From source (development mode)
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Requires Python 3.13+. Runtime dependencies: `requests` and `PyYAML`.
|
|
105
|
+
|
|
106
|
+
## Library quick start
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from lupaxa.slackit import Slackit, load_profile
|
|
110
|
+
|
|
111
|
+
profile = load_profile("testing")
|
|
112
|
+
client = Slackit(
|
|
113
|
+
profile.webhook_url,
|
|
114
|
+
username=profile.username,
|
|
115
|
+
channel=profile.channel,
|
|
116
|
+
icon_emoji=profile.icon_emoji,
|
|
117
|
+
)
|
|
118
|
+
client.send_message("Hello from a profile")
|
|
119
|
+
|
|
120
|
+
client = Slackit(
|
|
121
|
+
"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX",
|
|
122
|
+
username="PyBot",
|
|
123
|
+
channel="#testing",
|
|
124
|
+
icon_emoji=":robot_face:",
|
|
125
|
+
)
|
|
126
|
+
client.send_message("Hello, Slack!")
|
|
127
|
+
client.send_attachment('{"text": "Optional text inside the attachment"}')
|
|
128
|
+
client.send_block('[{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}]')
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## CLI quick start
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
slackit --help
|
|
135
|
+
slackit --webhook "$SLACK_WEBHOOK_URL" --text "Hello, Slack!"
|
|
136
|
+
slackit --profile testing --text "Hello, Slack!"
|
|
137
|
+
slackit -p alerts --config "$HOME/work/slackit.yml" --text "Disk full"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
You can also run the CLI as a module:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m lupaxa.slackit --help
|
|
144
|
+
python -m lupaxa.slackit --version
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Config
|
|
148
|
+
|
|
149
|
+
Profiles live in one YAML file. The default path is `$HOME/.slackit.yml`.
|
|
150
|
+
Pass `--config` when the file lives somewhere else. `--config` requires
|
|
151
|
+
`--profile`. CLI flags override the selected profile.
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
profiles:
|
|
155
|
+
testing:
|
|
156
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX
|
|
157
|
+
username: PyBot
|
|
158
|
+
channel: "#testing"
|
|
159
|
+
icon_emoji: ":robot_face:"
|
|
160
|
+
timeout: 15
|
|
161
|
+
alerts:
|
|
162
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/YYYYYYYY
|
|
163
|
+
channel: "#alerts"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Each profile may set `webhook_url`, `username`, `channel`, `icon_emoji`, and
|
|
167
|
+
`timeout`. `--webhook` is required when you do not pass `--profile`.
|
|
168
|
+
|
|
169
|
+
## Options
|
|
170
|
+
|
|
171
|
+
- `--webhook`, `-w`: Slack incoming webhook URL; required when `--profile` is omitted
|
|
172
|
+
- `--profile`, `-p`: profile name in the config file
|
|
173
|
+
- `--config`: config file path; default `$HOME/.slackit.yml`
|
|
174
|
+
- `--text`, `-t`: message text
|
|
175
|
+
- `--attachment`, `-a`: one attachment as a JSON object
|
|
176
|
+
- `--blocks`, `-b`: Block Kit payload as JSON
|
|
177
|
+
- `--validate`: send a validation message to channel `general`
|
|
178
|
+
- `--username`, `-u`: display name
|
|
179
|
+
- `--channel`, `-c`: channel override
|
|
180
|
+
- `--icon-emoji`, `-i`: icon emoji
|
|
181
|
+
- `--timeout`, `-T`: request timeout in seconds; default `10`
|
|
182
|
+
- `--version`: print the package version
|
|
183
|
+
|
|
184
|
+
`--text`, `--attachment`, `--blocks`, and `--validate` are mutually exclusive.
|
|
185
|
+
One of them is required. CLI flags override the selected profile.
|
|
186
|
+
|
|
187
|
+
## Documentation
|
|
188
|
+
|
|
189
|
+
Online documentation:
|
|
190
|
+
|
|
191
|
+
[Documentation](https://slackit.thelupaxaproject.org/)
|
|
192
|
+
|
|
193
|
+
Source repository:
|
|
194
|
+
|
|
195
|
+
[GitHub](https://github.com/lupaxa-notifications-toolbox/slackit)
|
|
196
|
+
|
|
197
|
+
### Serve docs locally
|
|
198
|
+
|
|
199
|
+
From a clone of the repository:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
make mkdocs-serve
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Then open the local URL printed by MkDocs in your browser.
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
Clone the repository and install with Make:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
make init # first-time makefile-skills checkout
|
|
213
|
+
make python-install-dev # editable install with [dev]
|
|
214
|
+
make python-check # lint, type-check, and test
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
<a href="https://github.com/the-lupaxa-project">
|
|
218
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
|
|
219
|
+
</a>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<!-- markdownlint-disable -->
|
|
2
|
+
<p align="center">
|
|
3
|
+
<a href="https://github.com/lupaxa-notifications-toolbox">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/notifications-toolbox/readme-logo.png" alt="Project Logo" width="256"/><br/>
|
|
5
|
+
</a>
|
|
6
|
+
</p>
|
|
7
|
+
<h3 align="center">
|
|
8
|
+
The Lupaxa Notifications Toolbox<br />
|
|
9
|
+
Part of The Lupaxa Project
|
|
10
|
+
</h3>
|
|
11
|
+
|
|
12
|
+
<br />
|
|
13
|
+
|
|
14
|
+
# lupaxa-slackit
|
|
15
|
+
|
|
16
|
+
Send Slack messages through an incoming webhook.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Post message text, one attachment, or Block Kit blocks
|
|
21
|
+
- Set a username, channel, and icon emoji on each client
|
|
22
|
+
- Keep several webhook profiles in `$HOME/.slackit.yml` and select one with `--profile`
|
|
23
|
+
- Override any profile field with a CLI flag
|
|
24
|
+
- Turn escaped `\n` sequences in message text into real line breaks
|
|
25
|
+
- Reject webhook URLs that do not start with `https://hooks.slack.com/services/`
|
|
26
|
+
- Reject webhook URLs that answer `HEAD` with a redirect
|
|
27
|
+
- Use the `Slackit` library class or the `slackit` command
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### From PyPI
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install lupaxa-slackit
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### From source (development mode)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ".[dev]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.13+. Runtime dependencies: `requests` and `PyYAML`.
|
|
44
|
+
|
|
45
|
+
## Library quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from lupaxa.slackit import Slackit, load_profile
|
|
49
|
+
|
|
50
|
+
profile = load_profile("testing")
|
|
51
|
+
client = Slackit(
|
|
52
|
+
profile.webhook_url,
|
|
53
|
+
username=profile.username,
|
|
54
|
+
channel=profile.channel,
|
|
55
|
+
icon_emoji=profile.icon_emoji,
|
|
56
|
+
)
|
|
57
|
+
client.send_message("Hello from a profile")
|
|
58
|
+
|
|
59
|
+
client = Slackit(
|
|
60
|
+
"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX",
|
|
61
|
+
username="PyBot",
|
|
62
|
+
channel="#testing",
|
|
63
|
+
icon_emoji=":robot_face:",
|
|
64
|
+
)
|
|
65
|
+
client.send_message("Hello, Slack!")
|
|
66
|
+
client.send_attachment('{"text": "Optional text inside the attachment"}')
|
|
67
|
+
client.send_block('[{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}]')
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## CLI quick start
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
slackit --help
|
|
74
|
+
slackit --webhook "$SLACK_WEBHOOK_URL" --text "Hello, Slack!"
|
|
75
|
+
slackit --profile testing --text "Hello, Slack!"
|
|
76
|
+
slackit -p alerts --config "$HOME/work/slackit.yml" --text "Disk full"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
You can also run the CLI as a module:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m lupaxa.slackit --help
|
|
83
|
+
python -m lupaxa.slackit --version
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Config
|
|
87
|
+
|
|
88
|
+
Profiles live in one YAML file. The default path is `$HOME/.slackit.yml`.
|
|
89
|
+
Pass `--config` when the file lives somewhere else. `--config` requires
|
|
90
|
+
`--profile`. CLI flags override the selected profile.
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
profiles:
|
|
94
|
+
testing:
|
|
95
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX
|
|
96
|
+
username: PyBot
|
|
97
|
+
channel: "#testing"
|
|
98
|
+
icon_emoji: ":robot_face:"
|
|
99
|
+
timeout: 15
|
|
100
|
+
alerts:
|
|
101
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/YYYYYYYY
|
|
102
|
+
channel: "#alerts"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each profile may set `webhook_url`, `username`, `channel`, `icon_emoji`, and
|
|
106
|
+
`timeout`. `--webhook` is required when you do not pass `--profile`.
|
|
107
|
+
|
|
108
|
+
## Options
|
|
109
|
+
|
|
110
|
+
- `--webhook`, `-w`: Slack incoming webhook URL; required when `--profile` is omitted
|
|
111
|
+
- `--profile`, `-p`: profile name in the config file
|
|
112
|
+
- `--config`: config file path; default `$HOME/.slackit.yml`
|
|
113
|
+
- `--text`, `-t`: message text
|
|
114
|
+
- `--attachment`, `-a`: one attachment as a JSON object
|
|
115
|
+
- `--blocks`, `-b`: Block Kit payload as JSON
|
|
116
|
+
- `--validate`: send a validation message to channel `general`
|
|
117
|
+
- `--username`, `-u`: display name
|
|
118
|
+
- `--channel`, `-c`: channel override
|
|
119
|
+
- `--icon-emoji`, `-i`: icon emoji
|
|
120
|
+
- `--timeout`, `-T`: request timeout in seconds; default `10`
|
|
121
|
+
- `--version`: print the package version
|
|
122
|
+
|
|
123
|
+
`--text`, `--attachment`, `--blocks`, and `--validate` are mutually exclusive.
|
|
124
|
+
One of them is required. CLI flags override the selected profile.
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
Online documentation:
|
|
129
|
+
|
|
130
|
+
[Documentation](https://slackit.thelupaxaproject.org/)
|
|
131
|
+
|
|
132
|
+
Source repository:
|
|
133
|
+
|
|
134
|
+
[GitHub](https://github.com/lupaxa-notifications-toolbox/slackit)
|
|
135
|
+
|
|
136
|
+
### Serve docs locally
|
|
137
|
+
|
|
138
|
+
From a clone of the repository:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
make mkdocs-serve
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then open the local URL printed by MkDocs in your browser.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
Clone the repository and install with Make:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
make init # first-time makefile-skills checkout
|
|
152
|
+
make python-install-dev # editable install with [dev]
|
|
153
|
+
make python-check # lint, type-check, and test
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
<a href="https://github.com/the-lupaxa-project">
|
|
157
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
|
|
158
|
+
</a>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/lupaxa-notifications-toolbox">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/notifications-toolbox/readme-logo.png" alt="Notifications Toolbox" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">slackit</h1>
|
|
8
|
+
|
|
9
|
+
Send Slack messages through an incoming webhook.
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://slackit.thelupaxaproject.org/">Documentation</a>
|
|
13
|
+
·
|
|
14
|
+
<a href="https://github.com/lupaxa-notifications-toolbox/slackit">GitHub</a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install lupaxa-slackit
|
|
21
|
+
slackit --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## CLI
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
slackit --webhook "$SLACK_WEBHOOK_URL" --text "Hello, Slack!"
|
|
28
|
+
slackit --profile testing --text "Hello, Slack!"
|
|
29
|
+
slackit -p alerts --config "$HOME/work/slackit.yml" --text "Disk full"
|
|
30
|
+
python -m lupaxa.slackit --version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The webhook URL must start with `https://hooks.slack.com/services/`.
|
|
34
|
+
`--text`, `--attachment`, `--blocks`, and `--validate` are mutually exclusive.
|
|
35
|
+
Flags override the same fields from the selected profile.
|
|
36
|
+
|
|
37
|
+
## Config
|
|
38
|
+
|
|
39
|
+
Profiles live in `$HOME/.slackit.yml`. Pass `--config` to use another file.
|
|
40
|
+
`--config` requires `--profile`.
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
profiles:
|
|
44
|
+
testing:
|
|
45
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX
|
|
46
|
+
username: PyBot
|
|
47
|
+
channel: "#testing"
|
|
48
|
+
icon_emoji: ":robot_face:"
|
|
49
|
+
timeout: 15
|
|
50
|
+
alerts:
|
|
51
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/YYYYYYYY
|
|
52
|
+
channel: "#alerts"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each profile may set `webhook_url`, `username`, `channel`, `icon_emoji`, and
|
|
56
|
+
`timeout`.
|
|
57
|
+
|
|
58
|
+
## Library
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from lupaxa.slackit import Slackit, load_profile
|
|
62
|
+
|
|
63
|
+
profile = load_profile("testing")
|
|
64
|
+
client = Slackit(
|
|
65
|
+
profile.webhook_url,
|
|
66
|
+
username=profile.username,
|
|
67
|
+
channel=profile.channel,
|
|
68
|
+
icon_emoji=profile.icon_emoji,
|
|
69
|
+
)
|
|
70
|
+
client.send_message("Hello, Slack!")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
make init
|
|
77
|
+
make python-install-dev
|
|
78
|
+
make python-check
|
|
79
|
+
make mkdocs-serve
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
<a href="https://github.com/the-lupaxa-project">
|
|
83
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
|
|
84
|
+
</a>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/lupaxa-notifications-toolbox">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/notifications-toolbox/readme-logo.png" alt="Organisation Logo" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">Core Documentation</h1>
|
|
8
|
+
|
|
9
|
+
The following documents define the standards that apply across **The Lupaxa Project**:
|
|
10
|
+
|
|
11
|
+
| Document | Purpose |
|
|
12
|
+
| :------------------------------------ | :---------------------------------------------------------------- |
|
|
13
|
+
| [Code of Conduct](CODE_OF_CONDUCT.md) | Expected behaviour and reporting procedures. |
|
|
14
|
+
| [How to Contribute](CONTRIBUTING.md) | Development workflow, coding standards, and contribution process. |
|
|
15
|
+
| [Governance](GOVERNANCE.md) | Project governance, roles, and decision-making. |
|
|
16
|
+
| [Maintainers](MAINTAINERS.md) | Maintainer responsibilities and expectations. |
|
|
17
|
+
| [Projects Overview](PROJECTS.md) | Overview of organisations, repositories, and project structure. |
|
|
18
|
+
| [Security Policy](SECURITY.md) | Responsible disclosure and secure development practices. |
|
|
19
|
+
| [Support Guide](SUPPORT.md) | How to obtain help and support. |
|
|
20
|
+
|
|
21
|
+
<a href="https://github.com/the-lupaxa-project">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
|
|
23
|
+
</a>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lupaxa-slackit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Send Slack messages through an incoming webhook."
|
|
9
|
+
readme = "README-PYPI.md"
|
|
10
|
+
license = { file = "LICENCE" }
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "The Lupaxa Project" }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
keywords = ["slack", "webhook", "notifications", "cli"]
|
|
18
|
+
|
|
19
|
+
dependencies = [
|
|
20
|
+
"requests>=2.32",
|
|
21
|
+
"PyYAML>=6",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Environment :: Console",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Intended Audience :: System Administrators",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Communications :: Chat",
|
|
35
|
+
"Topic :: Utilities",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://slackit.thelupaxaproject.org/"
|
|
40
|
+
Documentation = "https://slackit.thelupaxaproject.org/"
|
|
41
|
+
Repository = "https://github.com/lupaxa-notifications-toolbox/slackit"
|
|
42
|
+
Issues = "https://github.com/lupaxa-notifications-toolbox/slackit/issues"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
slackit = "lupaxa.slackit.cli:main"
|
|
46
|
+
|
|
47
|
+
[project.optional-dependencies]
|
|
48
|
+
dev = [
|
|
49
|
+
"pytest>=8.0",
|
|
50
|
+
"pytest-cov>=5.0",
|
|
51
|
+
"ruff>=0.6.0",
|
|
52
|
+
"mypy>=1.12.0",
|
|
53
|
+
"hatch>=1.12.0",
|
|
54
|
+
"bump-my-version>=1.2.0",
|
|
55
|
+
"pip-audit>=2.7.0",
|
|
56
|
+
"mkdocs==1.6.1",
|
|
57
|
+
"mkdocs-material==9.7.7",
|
|
58
|
+
]
|
|
59
|
+
test = [
|
|
60
|
+
"pytest>=8.0",
|
|
61
|
+
"pytest-cov>=5.0",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.hatch.version]
|
|
65
|
+
path = "src/lupaxa/slackit/version.py"
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.sdist]
|
|
68
|
+
include = [
|
|
69
|
+
"src/**",
|
|
70
|
+
"README.md",
|
|
71
|
+
"README-PYPI.md",
|
|
72
|
+
"LICENCE",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.wheel]
|
|
76
|
+
packages = ["src/lupaxa"]
|
|
77
|
+
|
|
78
|
+
[tool.ruff]
|
|
79
|
+
line-length = 100
|
|
80
|
+
target-version = "py313"
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint]
|
|
83
|
+
select = ["E", "F", "I", "B", "S", "UP", "SIM", "C4", "ISC", "D", "ANN", "PT"]
|
|
84
|
+
ignore = [
|
|
85
|
+
"D200", "D201", "D202", "D203", "D204", "D205", "D206", "D207", "D208", "D209", "D210", "D211", "D212",
|
|
86
|
+
"ISC001",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.per-file-ignores]
|
|
90
|
+
"tests/**/*.py" = ["S101", "S104", "S105", "S106", "D", "ANN001", "ANN201"]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint.pydocstyle]
|
|
93
|
+
convention = "numpy"
|
|
94
|
+
|
|
95
|
+
[tool.ruff.format]
|
|
96
|
+
quote-style = "double"
|
|
97
|
+
|
|
98
|
+
[tool.mypy]
|
|
99
|
+
python_version = "3.13"
|
|
100
|
+
files = ["src", "tests"]
|
|
101
|
+
mypy_path = "src"
|
|
102
|
+
explicit_package_bases = true
|
|
103
|
+
namespace_packages = true
|
|
104
|
+
warn_unused_configs = true
|
|
105
|
+
warn_return_any = true
|
|
106
|
+
warn_unused_ignores = true
|
|
107
|
+
disallow_untyped_defs = true
|
|
108
|
+
follow_imports = "normal"
|
|
109
|
+
ignore_missing_imports = true
|
|
110
|
+
|
|
111
|
+
[tool.pytest.ini_options]
|
|
112
|
+
addopts = "-ra -q --disable-warnings --cov=lupaxa.slackit --cov-report=term-missing"
|
|
113
|
+
testpaths = ["tests"]
|
|
114
|
+
pythonpath = ["src"]
|
|
115
|
+
|
|
116
|
+
[tool.coverage.run]
|
|
117
|
+
source = ["lupaxa.slackit"]
|
|
118
|
+
branch = true
|
|
119
|
+
|
|
120
|
+
[tool.coverage.report]
|
|
121
|
+
show_missing = true
|
|
122
|
+
skip_empty = true
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""lupaxa.slackit — send Slack messages through an incoming webhook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_TIMEOUT, WEBHOOK_PREFIX, Slackit
|
|
6
|
+
from .config import ConfigError, Profile, default_config_path, load_profile
|
|
7
|
+
from .version import __version__, get_version
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DEFAULT_TIMEOUT",
|
|
11
|
+
"WEBHOOK_PREFIX",
|
|
12
|
+
"ConfigError",
|
|
13
|
+
"Profile",
|
|
14
|
+
"Slackit",
|
|
15
|
+
"__version__",
|
|
16
|
+
"default_config_path",
|
|
17
|
+
"get_version",
|
|
18
|
+
"load_profile",
|
|
19
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Command-line interface for Slackit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .client import DEFAULT_TIMEOUT, Slackit
|
|
14
|
+
from .config import ConfigError, resolve_settings
|
|
15
|
+
from .version import get_version
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _positive_timeout(value: str) -> float:
|
|
19
|
+
try:
|
|
20
|
+
timeout = float(value)
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise argparse.ArgumentTypeError("timeout must be a number") from exc
|
|
23
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
24
|
+
raise argparse.ArgumentTypeError("timeout must be greater than 0")
|
|
25
|
+
return timeout
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _exit_code(exc: SystemExit) -> int:
|
|
29
|
+
code = exc.code
|
|
30
|
+
if code is None:
|
|
31
|
+
return 0
|
|
32
|
+
return code if isinstance(code, int) else 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _program_name(argv0: str) -> str:
|
|
36
|
+
name = os.path.basename(argv0)
|
|
37
|
+
return "slackit" if name == "__main__.py" else name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
"""Build the ``slackit`` argument parser."""
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
description="Send a Slack message through an incoming webhook.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-w",
|
|
47
|
+
"--webhook",
|
|
48
|
+
default=None,
|
|
49
|
+
metavar="URL",
|
|
50
|
+
help="Slack incoming webhook URL (overrides the profile)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"-p",
|
|
54
|
+
"--profile",
|
|
55
|
+
default=None,
|
|
56
|
+
metavar="NAME",
|
|
57
|
+
help="Profile name in the config file",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--config",
|
|
61
|
+
default=None,
|
|
62
|
+
metavar="PATH",
|
|
63
|
+
help="Config file (default: ~/.slackit.yml)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("-u", "--username", default=None, help="Display name")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"-c",
|
|
68
|
+
"--channel",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Channel, such as #testing",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"-i",
|
|
74
|
+
"--icon-emoji",
|
|
75
|
+
dest="icon_emoji",
|
|
76
|
+
default=None,
|
|
77
|
+
help="Icon emoji, such as :robot_face:",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"-T",
|
|
81
|
+
"--timeout",
|
|
82
|
+
type=_positive_timeout,
|
|
83
|
+
default=None,
|
|
84
|
+
metavar="SECONDS",
|
|
85
|
+
help=f"Request timeout in seconds (default: {DEFAULT_TIMEOUT:g})",
|
|
86
|
+
)
|
|
87
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
88
|
+
mode.add_argument("-t", "--text", help="Message text")
|
|
89
|
+
mode.add_argument("-a", "--attachment", help="Attachment JSON object")
|
|
90
|
+
mode.add_argument("-b", "--blocks", help="Block Kit JSON")
|
|
91
|
+
mode.add_argument(
|
|
92
|
+
"--validate",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Send a validation message to #general",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument("--version", action="version", version=get_version())
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str] | None = None) -> int:
|
|
101
|
+
"""Run the CLI and return a process exit code."""
|
|
102
|
+
parser = build_parser()
|
|
103
|
+
try:
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
except SystemExit as exc:
|
|
106
|
+
return _exit_code(exc)
|
|
107
|
+
|
|
108
|
+
config_path = Path(args.config).expanduser() if args.config else None
|
|
109
|
+
try:
|
|
110
|
+
settings = resolve_settings(
|
|
111
|
+
profile_name=args.profile,
|
|
112
|
+
config_path=config_path,
|
|
113
|
+
webhook_url=args.webhook,
|
|
114
|
+
username=args.username,
|
|
115
|
+
channel=args.channel,
|
|
116
|
+
icon_emoji=args.icon_emoji,
|
|
117
|
+
timeout=args.timeout,
|
|
118
|
+
)
|
|
119
|
+
client = Slackit(
|
|
120
|
+
settings.webhook_url,
|
|
121
|
+
username=settings.username,
|
|
122
|
+
channel=settings.channel,
|
|
123
|
+
icon_emoji=settings.icon_emoji,
|
|
124
|
+
timeout=settings.timeout,
|
|
125
|
+
)
|
|
126
|
+
if args.validate:
|
|
127
|
+
if not client.validate():
|
|
128
|
+
print(f"{_program_name(sys.argv[0])}: invalid webhook URL", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
return 0
|
|
131
|
+
if args.text is not None:
|
|
132
|
+
client.send_message(args.text)
|
|
133
|
+
elif args.attachment is not None:
|
|
134
|
+
client.send_attachment(args.attachment)
|
|
135
|
+
elif args.blocks is not None:
|
|
136
|
+
client.send_block(args.blocks)
|
|
137
|
+
except ConfigError as exc:
|
|
138
|
+
print(f"{_program_name(sys.argv[0])}: {exc}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
except (ValueError, requests.RequestException) as exc:
|
|
141
|
+
print(f"{_program_name(sys.argv[0])}: {exc}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
return 0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Slack incoming-webhook client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMEOUT = 10.0
|
|
12
|
+
WEBHOOK_PREFIX = "https://hooks.slack.com/services/"
|
|
13
|
+
_REDIRECT_STATUSES = {301, 302}
|
|
14
|
+
|
|
15
|
+
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Slackit:
|
|
19
|
+
"""Send messages through a Slack incoming webhook."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
webhook_url: str,
|
|
24
|
+
*,
|
|
25
|
+
username: str | None = None,
|
|
26
|
+
channel: str | None = None,
|
|
27
|
+
icon_emoji: str | None = None,
|
|
28
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Store webhook settings after the URL passes validation."""
|
|
31
|
+
if not webhook_url:
|
|
32
|
+
raise ValueError("Webhook URL required")
|
|
33
|
+
if not webhook_url.startswith(WEBHOOK_PREFIX):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Invalid webhook URL: {webhook_url} - should start with {WEBHOOK_PREFIX}"
|
|
36
|
+
)
|
|
37
|
+
self.webhook_url = webhook_url
|
|
38
|
+
self.username = username
|
|
39
|
+
self.channel = channel
|
|
40
|
+
self.icon_emoji = icon_emoji
|
|
41
|
+
self.timeout = _require_timeout(timeout)
|
|
42
|
+
if not self.valid_webhook(self.webhook_url):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Invalid webhook URL: {self.webhook_url} - please check your configuration"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def valid_webhook(self, url: str) -> bool:
|
|
48
|
+
"""Return whether ``url`` answers without a redirect."""
|
|
49
|
+
try:
|
|
50
|
+
response = requests.head(
|
|
51
|
+
url,
|
|
52
|
+
allow_redirects=False,
|
|
53
|
+
timeout=self.timeout,
|
|
54
|
+
)
|
|
55
|
+
except requests.RequestException:
|
|
56
|
+
return False
|
|
57
|
+
return response.status_code not in _REDIRECT_STATUSES
|
|
58
|
+
|
|
59
|
+
def send_message(self, text: str) -> bool:
|
|
60
|
+
"""Post ``text``, turning escaped newlines into real line breaks."""
|
|
61
|
+
return self.send_payload({"text": text.replace("\\n", "\n")})
|
|
62
|
+
|
|
63
|
+
def send(self, text: str) -> bool:
|
|
64
|
+
"""Post ``text``. Alias of ``send_message``."""
|
|
65
|
+
return self.send_message(text)
|
|
66
|
+
|
|
67
|
+
def send_attachment(self, attachment: str) -> bool:
|
|
68
|
+
"""Post one attachment encoded as a JSON object string."""
|
|
69
|
+
return self.send_payload({"attachments": [self.convert_to_json(attachment)]})
|
|
70
|
+
|
|
71
|
+
def send_block(self, block: str) -> bool:
|
|
72
|
+
"""Post Block Kit blocks encoded as a JSON string."""
|
|
73
|
+
return self.send_payload({"blocks": self.convert_to_json(block)})
|
|
74
|
+
|
|
75
|
+
def convert_to_json(self, json_string: str) -> JsonValue:
|
|
76
|
+
"""Parse ``json_string`` or raise ``ValueError``."""
|
|
77
|
+
try:
|
|
78
|
+
parsed = json.loads(json_string)
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise ValueError("Invalid json") from exc
|
|
81
|
+
return cast(JsonValue, parsed)
|
|
82
|
+
|
|
83
|
+
def send_payload(self, payload: dict[str, JsonValue]) -> bool:
|
|
84
|
+
"""POST ``payload`` as JSON, filling username, channel, and icon."""
|
|
85
|
+
body = _with_defaults(payload, self.icon_emoji, self.username, self.channel)
|
|
86
|
+
response = requests.post(
|
|
87
|
+
self.webhook_url,
|
|
88
|
+
headers={"Content-Type": "application/json"},
|
|
89
|
+
json=body,
|
|
90
|
+
timeout=self.timeout,
|
|
91
|
+
allow_redirects=False,
|
|
92
|
+
)
|
|
93
|
+
if response.status_code == 200:
|
|
94
|
+
return True
|
|
95
|
+
if response.status_code in _REDIRECT_STATUSES:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Invalid webhook URL: {self.webhook_url} - please check your configuration"
|
|
98
|
+
)
|
|
99
|
+
if not response.text:
|
|
100
|
+
raise ValueError(f"Unknown error for webhook URL: {self.webhook_url}")
|
|
101
|
+
raise ValueError(response.text)
|
|
102
|
+
|
|
103
|
+
def validate(self) -> bool:
|
|
104
|
+
"""Send a validation message to channel ``general`` when HEAD succeeds."""
|
|
105
|
+
if not self.valid_webhook(self.webhook_url):
|
|
106
|
+
return False
|
|
107
|
+
self.channel = "general"
|
|
108
|
+
self.send_message("This is a validation message")
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _require_timeout(timeout: float) -> float:
|
|
113
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
114
|
+
raise ValueError("timeout must be greater than 0")
|
|
115
|
+
return timeout
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _with_defaults(
|
|
119
|
+
payload: dict[str, JsonValue],
|
|
120
|
+
icon_emoji: str | None,
|
|
121
|
+
username: str | None,
|
|
122
|
+
channel: str | None,
|
|
123
|
+
) -> dict[str, JsonValue]:
|
|
124
|
+
body = dict(payload)
|
|
125
|
+
for key, value in (
|
|
126
|
+
("icon_emoji", icon_emoji),
|
|
127
|
+
("username", username),
|
|
128
|
+
("channel", channel),
|
|
129
|
+
):
|
|
130
|
+
if key not in body and value is not None:
|
|
131
|
+
body[key] = value
|
|
132
|
+
return body
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Load Slackit profiles from a YAML config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .client import DEFAULT_TIMEOUT
|
|
12
|
+
|
|
13
|
+
_PROFILE_FIELDS = ("webhook_url", "username", "channel", "icon_emoji", "timeout")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(ValueError):
|
|
17
|
+
"""The config file or selected profile cannot be used."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Profile:
|
|
22
|
+
"""One named block of webhook settings from the config file."""
|
|
23
|
+
|
|
24
|
+
webhook_url: str | None = None
|
|
25
|
+
username: str | None = None
|
|
26
|
+
channel: str | None = None
|
|
27
|
+
icon_emoji: str | None = None
|
|
28
|
+
timeout: float | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ResolvedSettings:
|
|
33
|
+
"""Webhook settings after CLI flags override a profile."""
|
|
34
|
+
|
|
35
|
+
webhook_url: str
|
|
36
|
+
username: str | None
|
|
37
|
+
channel: str | None
|
|
38
|
+
icon_emoji: str | None
|
|
39
|
+
timeout: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_config_path() -> Path:
|
|
43
|
+
"""Return the default config path, ``$HOME/.slackit.yml``."""
|
|
44
|
+
return Path.home() / ".slackit.yml"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_profile(name: str, path: Path | None = None) -> Profile:
|
|
48
|
+
"""Load ``name`` from ``path`` or from ``$HOME/.slackit.yml``."""
|
|
49
|
+
config_path = default_config_path() if path is None else path
|
|
50
|
+
if not config_path.is_file():
|
|
51
|
+
raise ConfigError(f"config file not found: {config_path}")
|
|
52
|
+
try:
|
|
53
|
+
loaded: object = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
54
|
+
except yaml.YAMLError as exc:
|
|
55
|
+
raise ConfigError(f"invalid config file: {config_path}") from exc
|
|
56
|
+
profiles = _profiles(loaded, config_path)
|
|
57
|
+
if name not in profiles:
|
|
58
|
+
raise ConfigError(f"profile not found: {name}")
|
|
59
|
+
raw = profiles[name]
|
|
60
|
+
if not isinstance(raw, dict):
|
|
61
|
+
raise ConfigError(f"profile {name} must be a mapping")
|
|
62
|
+
unknown = sorted(str(key) for key in raw if key not in _PROFILE_FIELDS)
|
|
63
|
+
if unknown:
|
|
64
|
+
raise ConfigError(f"unknown profile setting: {', '.join(unknown)}")
|
|
65
|
+
return Profile(
|
|
66
|
+
webhook_url=_optional_text(raw.get("webhook_url"), "webhook_url"),
|
|
67
|
+
username=_optional_text(raw.get("username"), "username"),
|
|
68
|
+
channel=_optional_text(raw.get("channel"), "channel"),
|
|
69
|
+
icon_emoji=_optional_text(raw.get("icon_emoji"), "icon_emoji"),
|
|
70
|
+
timeout=_optional_timeout(raw.get("timeout")),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_settings(
|
|
75
|
+
*,
|
|
76
|
+
profile_name: str | None,
|
|
77
|
+
config_path: Path | None,
|
|
78
|
+
webhook_url: str | None,
|
|
79
|
+
username: str | None,
|
|
80
|
+
channel: str | None,
|
|
81
|
+
icon_emoji: str | None,
|
|
82
|
+
timeout: float | None,
|
|
83
|
+
) -> ResolvedSettings:
|
|
84
|
+
"""Merge CLI values over an optional profile.
|
|
85
|
+
|
|
86
|
+
A passed CLI value wins. ``timeout`` falls back to ``DEFAULT_TIMEOUT``
|
|
87
|
+
when neither the CLI nor the profile sets it.
|
|
88
|
+
"""
|
|
89
|
+
if profile_name is None and config_path is not None:
|
|
90
|
+
raise ConfigError("--profile is required when --config is set")
|
|
91
|
+
profile = load_profile(profile_name, config_path) if profile_name else None
|
|
92
|
+
resolved_webhook = _pick(webhook_url, None if profile is None else profile.webhook_url)
|
|
93
|
+
if not resolved_webhook:
|
|
94
|
+
raise ConfigError("webhook URL required")
|
|
95
|
+
if timeout is not None:
|
|
96
|
+
resolved_timeout = timeout
|
|
97
|
+
elif profile is not None and profile.timeout is not None:
|
|
98
|
+
resolved_timeout = profile.timeout
|
|
99
|
+
else:
|
|
100
|
+
resolved_timeout = DEFAULT_TIMEOUT
|
|
101
|
+
return ResolvedSettings(
|
|
102
|
+
webhook_url=resolved_webhook,
|
|
103
|
+
username=_pick(username, None if profile is None else profile.username),
|
|
104
|
+
channel=_pick(channel, None if profile is None else profile.channel),
|
|
105
|
+
icon_emoji=_pick(icon_emoji, None if profile is None else profile.icon_emoji),
|
|
106
|
+
timeout=resolved_timeout,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pick(cli_value: str | None, profile_value: str | None) -> str | None:
|
|
111
|
+
return cli_value if cli_value is not None else profile_value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _profiles(loaded: object, config_path: Path) -> dict[object, object]:
|
|
115
|
+
if not isinstance(loaded, dict):
|
|
116
|
+
raise ConfigError(f"invalid config file: {config_path}")
|
|
117
|
+
unknown = sorted(str(key) for key in loaded if key != "profiles")
|
|
118
|
+
if unknown:
|
|
119
|
+
raise ConfigError(f"unknown config setting: {', '.join(unknown)}")
|
|
120
|
+
profiles = loaded.get("profiles")
|
|
121
|
+
if not isinstance(profiles, dict) or not profiles:
|
|
122
|
+
raise ConfigError("config file must define profiles")
|
|
123
|
+
for name in profiles:
|
|
124
|
+
if not isinstance(name, str) or not name:
|
|
125
|
+
raise ConfigError("profile names must be strings")
|
|
126
|
+
return profiles
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _optional_text(value: object, field: str) -> str | None:
|
|
130
|
+
if value is None:
|
|
131
|
+
return None
|
|
132
|
+
if not isinstance(value, str) or not value:
|
|
133
|
+
raise ConfigError(f"{field} must be a non-empty string")
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _optional_timeout(value: object) -> float | None:
|
|
138
|
+
if value is None:
|
|
139
|
+
return None
|
|
140
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
141
|
+
raise ConfigError("timeout must be a number")
|
|
142
|
+
timeout = float(value)
|
|
143
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
144
|
+
raise ConfigError("timeout must be greater than 0")
|
|
145
|
+
return timeout
|