pernox 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,93 @@
1
+ # Releases pernox on every merge to main that changes the package: works out the next
2
+ # version from the latest v* tag, builds it, publishes it to PyPI, and then tags the commit.
3
+ #
4
+ # The bump is a patch (0.1.3 -> 0.1.4) unless the merge's commit message says otherwise:
5
+ # put #minor or #major in the pull request's title or description. The first release is
6
+ # 0.1.0.
7
+ #
8
+ # Nothing is committed back to main: the version lives only in the tag, which hatch-vcs
9
+ # reads when building. The tag is pushed after PyPI accepts the release, so a failed
10
+ # publish leaves no tag behind and the next merge tries the same version again.
11
+ #
12
+ # Publishing uses PyPI's trusted publishing, so no PyPI token is stored here: PyPI trusts
13
+ # this workflow, in the "pypi" environment, to upload pernox.
14
+ name: release
15
+
16
+ on:
17
+ push:
18
+ branches: [main]
19
+ paths: [src/**, pyproject.toml, README.md, LICENSE]
20
+
21
+ # One release at a time, in order, so two quick merges don't claim the same version.
22
+ concurrency:
23
+ group: release
24
+ cancel-in-progress: false
25
+
26
+ jobs:
27
+ test:
28
+ uses: ./.github/workflows/test.yml
29
+
30
+ build:
31
+ needs: test
32
+ runs-on: ubuntu-latest
33
+ outputs:
34
+ version: ${{ steps.next.outputs.version }}
35
+ steps:
36
+ - uses: actions/checkout@v7
37
+ with:
38
+ fetch-depth: 0 # every tag, to find the latest version
39
+ - uses: actions/setup-python@v7
40
+ with:
41
+ python-version: "3.14"
42
+ - name: Work out the next version
43
+ id: next
44
+ env:
45
+ MESSAGE: ${{ github.event.head_commit.message }}
46
+ run: |
47
+ latest=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1)
48
+ if [ -z "$latest" ]; then
49
+ next=0.1.0
50
+ else
51
+ IFS=. read -r major minor patch <<< "${latest#v}"
52
+ case "$MESSAGE" in
53
+ *"#major"*) next="$((major + 1)).0.0" ;;
54
+ *"#minor"*) next="$major.$((minor + 1)).0" ;;
55
+ *) next="$major.$minor.$((patch + 1))" ;;
56
+ esac
57
+ fi
58
+ echo "Releasing $next (latest was ${latest:-none})"
59
+ echo "version=$next" >> "$GITHUB_OUTPUT"
60
+ # Tagged here only so hatch-vcs builds this version; it's pushed once PyPI has it.
61
+ - run: git tag "v${{ steps.next.outputs.version }}"
62
+ - run: python -m pip install build
63
+ - run: python -m build
64
+ - name: Check the build has the right version
65
+ run: ls dist/ | grep -q "pernox-${{ steps.next.outputs.version }}-py3-none-any.whl"
66
+ - uses: actions/upload-artifact@v7
67
+ with:
68
+ name: dist
69
+ path: dist/
70
+
71
+ publish:
72
+ needs: build
73
+ runs-on: ubuntu-latest
74
+ environment: pypi
75
+ permissions:
76
+ id-token: write
77
+ steps:
78
+ - uses: actions/download-artifact@v8
79
+ with:
80
+ name: dist
81
+ path: dist/
82
+ - uses: pypa/gh-action-pypi-publish@release/v1
83
+
84
+ tag:
85
+ needs: [build, publish]
86
+ runs-on: ubuntu-latest
87
+ permissions:
88
+ contents: write
89
+ steps:
90
+ - uses: actions/checkout@v7
91
+ - run: |
92
+ git tag "v${{ needs.build.outputs.version }}" "$GITHUB_SHA"
93
+ git push origin "v${{ needs.build.outputs.version }}"
@@ -0,0 +1,21 @@
1
+ # Tests every pull request and push. The release workflow runs it too, before publishing.
2
+ name: test
3
+
4
+ on:
5
+ push:
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ # The oldest Python pernox supports, and the newest.
15
+ python: ["3.10", "3.14"]
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+ - uses: actions/setup-python@v7
19
+ with:
20
+ python-version: ${{ matrix.python }}
21
+ - run: PYTHONPATH=src python -m unittest discover -s tests -v
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
pernox-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastien Williams-Wynn
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.
pernox-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: pernox
3
+ Version: 0.1.0
4
+ Summary: Email yourself from a script running in nox.
5
+ Project-URL: Homepage, https://pernox.dev
6
+ Project-URL: Documentation, https://pernox.dev/docs#notify
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: cron,email,notifications,nox,scheduler
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # pernox
18
+
19
+ Email yourself from a script running in [nox](https://pernox.dev).
20
+
21
+ ```
22
+ pip install pernox
23
+ ```
24
+
25
+ ```python
26
+ from pernox import notify
27
+
28
+ notify("3 new flats under £1,500", title="Flat search")
29
+ ```
30
+
31
+ In nox, add `pernox` to the script's requirements. There's nothing else to set up: each run
32
+ is given its own token, which stops working when the run ends, and the email goes to your
33
+ nox account's address.
34
+
35
+ Notifications come with nox Pro, and go only to a verified address: verify yours in
36
+ [Settings](https://pernox.dev/settings#profile).
37
+
38
+ Run the script anywhere else and `notify` prints the message instead of sending it, so the
39
+ same script works on your own machine.
40
+
41
+ `notify` returns `True` once the email is sent, and `False` when it was only printed. If nox
42
+ can't send it, it raises `pernox.NotifyError`, which fails the run like any other exception.
43
+ Catch it if a notification failing shouldn't.
44
+
45
+ ## Arguments
46
+
47
+ | | |
48
+ |---|---|
49
+ | `message` | The text, up to 1,024 characters. |
50
+ | `title` | The subject line, up to 250 characters. Without one, it's the script's name. |
51
+ | `url` | A link to include. |
52
+
53
+ No dependencies beyond the standard library. Python 3.10 or later. MIT licensed.
pernox-0.1.0/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # pernox
2
+
3
+ Email yourself from a script running in [nox](https://pernox.dev).
4
+
5
+ ```
6
+ pip install pernox
7
+ ```
8
+
9
+ ```python
10
+ from pernox import notify
11
+
12
+ notify("3 new flats under £1,500", title="Flat search")
13
+ ```
14
+
15
+ In nox, add `pernox` to the script's requirements. There's nothing else to set up: each run
16
+ is given its own token, which stops working when the run ends, and the email goes to your
17
+ nox account's address.
18
+
19
+ Notifications come with nox Pro, and go only to a verified address: verify yours in
20
+ [Settings](https://pernox.dev/settings#profile).
21
+
22
+ Run the script anywhere else and `notify` prints the message instead of sending it, so the
23
+ same script works on your own machine.
24
+
25
+ `notify` returns `True` once the email is sent, and `False` when it was only printed. If nox
26
+ can't send it, it raises `pernox.NotifyError`, which fails the run like any other exception.
27
+ Catch it if a notification failing shouldn't.
28
+
29
+ ## Arguments
30
+
31
+ | | |
32
+ |---|---|
33
+ | `message` | The text, up to 1,024 characters. |
34
+ | `title` | The subject line, up to 250 characters. Without one, it's the script's name. |
35
+ | `url` | A link to include. |
36
+
37
+ No dependencies beyond the standard library. Python 3.10 or later. MIT licensed.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25", "hatch-vcs>=0.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pernox"
7
+ # The version is the latest v* git tag, which the release workflow creates on each merge.
8
+ dynamic = ["version"]
9
+ description = "Email yourself from a script running in nox."
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.10"
14
+ # No dependencies: it only needs the standard library, so it installs instantly in a run.
15
+ dependencies = []
16
+ keywords = ["nox", "notifications", "email", "scheduler", "cron"]
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Communications",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://pernox.dev"
26
+ Documentation = "https://pernox.dev/docs#notify"
27
+
28
+ [tool.hatch.version]
29
+ source = "vcs"
30
+
31
+ [tool.hatch.version.raw-options]
32
+ # Outside a git checkout with tags, as in a bare copy of the source.
33
+ fallback_version = "0.0.0"
@@ -0,0 +1,96 @@
1
+ """Send yourself a notification from a script running in nox.
2
+
3
+ from pernox import notify
4
+
5
+ notify("3 new flats under £1,500", title="Flat search")
6
+
7
+ Inside nox, each run is given a token (NOX_TOKEN) and nox's address (NOX_API_URL), so
8
+ there's nothing to set up. Anywhere else, notify prints the message instead of sending it,
9
+ so the same script runs on your own machine unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ import urllib.error
18
+ import urllib.request
19
+ from importlib.metadata import PackageNotFoundError, version
20
+ from typing import Optional
21
+
22
+ __all__ = ["notify", "NotifyError"]
23
+ try:
24
+ __version__ = version("pernox")
25
+ except PackageNotFoundError: # run from the source tree without installing
26
+ __version__ = "0.0.0"
27
+
28
+ _DEFAULT_API_URL = "https://pernox.dev"
29
+
30
+
31
+ class NotifyError(Exception):
32
+ """nox couldn't send the notification. status is the HTTP status, if nox answered."""
33
+
34
+ def __init__(self, message: str, status: Optional[int] = None) -> None:
35
+ super().__init__(message)
36
+ self.status = status
37
+
38
+
39
+ def notify(
40
+ message: str,
41
+ title: Optional[str] = None,
42
+ *,
43
+ url: Optional[str] = None,
44
+ timeout: float = 60,
45
+ ) -> bool:
46
+ """Email the script's owner, at their nox account's address.
47
+
48
+ message: the text, up to 1,024 characters.
49
+ title: the subject line, up to 250 characters. Without one, it's the script's name.
50
+ url: a link to include.
51
+
52
+ Returns True once sent, or False if it was printed instead because the script isn't
53
+ running in nox. Raises NotifyError if nox couldn't send it.
54
+ """
55
+ token = os.environ.get("NOX_TOKEN")
56
+ if not token:
57
+ heading = f"{title}: " if title else ""
58
+ print(f"[pernox] {heading}{message} (not sent: runs outside nox)", file=sys.stderr)
59
+ return False
60
+
61
+ body = {"channel": "email", "message": message}
62
+ if title is not None:
63
+ body["title"] = title
64
+ if url is not None:
65
+ body["url"] = url
66
+ api = os.environ.get("NOX_API_URL", _DEFAULT_API_URL).rstrip("/")
67
+ request = urllib.request.Request(
68
+ api + "/api/v1/notify",
69
+ data=json.dumps(body).encode(),
70
+ method="POST",
71
+ headers={
72
+ "Authorization": f"Bearer {token}",
73
+ "Content-Type": "application/json",
74
+ "User-Agent": f"pernox/{__version__}",
75
+ },
76
+ )
77
+ try:
78
+ with urllib.request.urlopen(request, timeout=timeout) as response:
79
+ response.read()
80
+ except urllib.error.HTTPError as e:
81
+ raise NotifyError(_error_message(e), e.code) from None
82
+ except (urllib.error.URLError, TimeoutError) as e:
83
+ reason = getattr(e, "reason", e)
84
+ raise NotifyError(f"Couldn't reach nox at {api}: {reason}") from None
85
+ return True
86
+
87
+
88
+ def _error_message(e: urllib.error.HTTPError) -> str:
89
+ """nox explains what went wrong in the body's "error"; fall back to the status."""
90
+ try:
91
+ message = json.loads(e.read())["error"]
92
+ if isinstance(message, str) and message:
93
+ return message
94
+ except (ValueError, KeyError, TypeError, OSError):
95
+ pass
96
+ return f"nox answered {e.code} {e.reason}"
File without changes
@@ -0,0 +1,86 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import threading
5
+ import unittest
6
+ from contextlib import redirect_stderr
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from unittest import mock
9
+
10
+ import pernox
11
+
12
+
13
+ class FakeNox(BaseHTTPRequestHandler):
14
+ """Answers /api/v1/notify like nox: the token "good" works, anything else doesn't."""
15
+
16
+ received = []
17
+
18
+ def do_POST(self):
19
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
20
+ FakeNox.received.append((self.path, self.headers["Authorization"], body))
21
+ if self.headers["Authorization"] != "Bearer good":
22
+ self.reply(401, {"error": "This token isn't valid."})
23
+ else:
24
+ self.reply(200, {"sent": True, "channel": body["channel"]})
25
+
26
+ def reply(self, status, body):
27
+ data = json.dumps(body).encode()
28
+ self.send_response(status)
29
+ self.send_header("Content-Type", "application/json")
30
+ self.send_header("Content-Length", str(len(data)))
31
+ self.end_headers()
32
+ self.wfile.write(data)
33
+
34
+ def log_message(self, *args):
35
+ pass
36
+
37
+
38
+ class NotifyTest(unittest.TestCase):
39
+ @classmethod
40
+ def setUpClass(cls):
41
+ cls.server = HTTPServer(("127.0.0.1", 0), FakeNox)
42
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
43
+ cls.url = f"http://127.0.0.1:{cls.server.server_port}"
44
+
45
+ @classmethod
46
+ def tearDownClass(cls):
47
+ cls.server.shutdown()
48
+
49
+ def setUp(self):
50
+ FakeNox.received.clear()
51
+
52
+ def env(self, token):
53
+ return mock.patch.dict(os.environ, {"NOX_TOKEN": token, "NOX_API_URL": self.url + "/"})
54
+
55
+ def test_sends_inside_nox(self):
56
+ with self.env("good"):
57
+ sent = pernox.notify("All done", title="Backup", url="https://example.com")
58
+ self.assertTrue(sent)
59
+ path, auth, body = FakeNox.received[0]
60
+ self.assertEqual(path, "/api/v1/notify")
61
+ self.assertEqual(auth, "Bearer good")
62
+ self.assertEqual(body, {"channel": "email", "message": "All done", "title": "Backup", "url": "https://example.com"})
63
+
64
+ def test_raises_nox_error_message(self):
65
+ with self.env("bad"), self.assertRaises(pernox.NotifyError) as caught:
66
+ pernox.notify("All done")
67
+ self.assertEqual(str(caught.exception), "This token isn't valid.")
68
+ self.assertEqual(caught.exception.status, 401)
69
+
70
+ def test_raises_when_nox_is_unreachable(self):
71
+ with mock.patch.dict(os.environ, {"NOX_TOKEN": "good", "NOX_API_URL": "http://127.0.0.1:9"}):
72
+ with self.assertRaises(pernox.NotifyError) as caught:
73
+ pernox.notify("All done")
74
+ self.assertIn("Couldn't reach nox", str(caught.exception))
75
+
76
+ def test_prints_outside_nox(self):
77
+ env = {k: v for k, v in os.environ.items() if k != "NOX_TOKEN"}
78
+ with mock.patch.dict(os.environ, env, clear=True), redirect_stderr(io.StringIO()) as err:
79
+ sent = pernox.notify("All done", title="Backup")
80
+ self.assertFalse(sent)
81
+ self.assertEqual(FakeNox.received, [])
82
+ self.assertIn("[pernox] Backup: All done (not sent: runs outside nox)", err.getvalue())
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main()