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.
@@ -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,8 @@
1
+ """Allow ``python -m lupaxa.slackit`` to run the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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
@@ -0,0 +1,10 @@
1
+ """Version information for lupaxa.slackit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+
8
+ def get_version() -> str:
9
+ """Return the package version string."""
10
+ return __version__