hugoh-repokit 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,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ public/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hugo Haas
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,72 @@
1
+ Metadata-Version: 2.5
2
+ Name: hugoh-repokit
3
+ Version: 0.1.0
4
+ Summary: Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin's scripts
5
+ Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/repokit
6
+ Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
7
+ Project-URL: Repository, https://github.com/hugoh/gh-workflows
8
+ Author: Hugo Haas
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: asyncgh>=0.2.0
21
+ Requires-Dist: reconcilekit>=0.2.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # repokit
25
+
26
+ Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin-style
27
+ scripts: fetch an account's repos via [`asyncgh`](../asyncgh/README.md), keep
28
+ the non-archived/non-fork (by default) ones matching an `only`/`skip` filter,
29
+ and run work over them in bounded parallel via
30
+ [`reconcilekit`](../reconcilekit/README.md). No config-file loading, no sops,
31
+ no fork/exclude policy — those are a caller concern, which is why
32
+ `list_repos`'s `include_forks` defaults to none included rather than reading
33
+ any file.
34
+
35
+ Published to PyPI as `hugoh-repokit`; imported as `repokit`.
36
+
37
+ ## API
38
+
39
+ Full API reference, generated from the docstrings:
40
+ [hugoh.github.io/gh-workflows/repokit](https://hugoh.github.io/gh-workflows/repokit/)
41
+ (rebuilt on every push that touches this package — see
42
+ `.github/workflows/docs.yml`).
43
+
44
+ ## Example
45
+
46
+ ```python
47
+ import asyncio
48
+
49
+ from repokit import DEFAULT_OWNER, list_repos, run_cli
50
+
51
+
52
+ async def main(args) -> int:
53
+ repos = await list_repos(DEFAULT_OWNER, only=args.only, skip=args.skip)
54
+ for repo in repos:
55
+ print(repo.name)
56
+ return 0
57
+
58
+
59
+ def cli(argv: list[str]) -> int:
60
+ return run_cli(main, parse_args(argv))
61
+ ```
62
+
63
+ ## Consumers
64
+
65
+ `repo-admin/` (in this repo) uses it for `repo_admin.py`/`activity.py`,
66
+ consumed as a uv workspace member there — `repo-admin/lib.py` layers its
67
+ own config-file-backed fork/exclude policy, sops-based secrets, and
68
+ `console`/`progress_bar`/etc. re-exports from `asyncgh`/`reconcilekit` on
69
+ top, kept local since none of that is generic.
70
+ [`digest-action`](https://github.com/hugoh/digest-action) (a separate repo,
71
+ since GitHub Marketplace only publishes actions from a repository root)
72
+ consumes it as a regular PyPI dependency.
@@ -0,0 +1,49 @@
1
+ # repokit
2
+
3
+ Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin-style
4
+ scripts: fetch an account's repos via [`asyncgh`](../asyncgh/README.md), keep
5
+ the non-archived/non-fork (by default) ones matching an `only`/`skip` filter,
6
+ and run work over them in bounded parallel via
7
+ [`reconcilekit`](../reconcilekit/README.md). No config-file loading, no sops,
8
+ no fork/exclude policy — those are a caller concern, which is why
9
+ `list_repos`'s `include_forks` defaults to none included rather than reading
10
+ any file.
11
+
12
+ Published to PyPI as `hugoh-repokit`; imported as `repokit`.
13
+
14
+ ## API
15
+
16
+ Full API reference, generated from the docstrings:
17
+ [hugoh.github.io/gh-workflows/repokit](https://hugoh.github.io/gh-workflows/repokit/)
18
+ (rebuilt on every push that touches this package — see
19
+ `.github/workflows/docs.yml`).
20
+
21
+ ## Example
22
+
23
+ ```python
24
+ import asyncio
25
+
26
+ from repokit import DEFAULT_OWNER, list_repos, run_cli
27
+
28
+
29
+ async def main(args) -> int:
30
+ repos = await list_repos(DEFAULT_OWNER, only=args.only, skip=args.skip)
31
+ for repo in repos:
32
+ print(repo.name)
33
+ return 0
34
+
35
+
36
+ def cli(argv: list[str]) -> int:
37
+ return run_cli(main, parse_args(argv))
38
+ ```
39
+
40
+ ## Consumers
41
+
42
+ `repo-admin/` (in this repo) uses it for `repo_admin.py`/`activity.py`,
43
+ consumed as a uv workspace member there — `repo-admin/lib.py` layers its
44
+ own config-file-backed fork/exclude policy, sops-based secrets, and
45
+ `console`/`progress_bar`/etc. re-exports from `asyncgh`/`reconcilekit` on
46
+ top, kept local since none of that is generic.
47
+ [`digest-action`](https://github.com/hugoh/digest-action) (a separate repo,
48
+ since GitHub Marketplace only publishes actions from a repository root)
49
+ consumes it as a regular PyPI dependency.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "hugoh-repokit"
3
+ version = "0.1.0"
4
+ description = "Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin's scripts"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Hugo Haas" },
11
+ ]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ dependencies = ["asyncgh>=0.2.0", "reconcilekit>=0.2.0"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/hugoh/gh-workflows/tree/main/repokit"
27
+ Issues = "https://github.com/hugoh/gh-workflows/issues"
28
+ Repository = "https://github.com/hugoh/gh-workflows"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/repokit"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ asyncio_mode = "auto"
43
+
44
+ [tool.uv.sources]
45
+ asyncgh = { workspace = true }
46
+ reconcilekit = { workspace = true }
@@ -0,0 +1,39 @@
1
+ """repokit -- repo listing/filtering and CLI-entrypoint plumbing shared by
2
+ repo-admin's scripts.
3
+
4
+ Fetches repos via `asyncgh` and runs bounded-parallel work over them via
5
+ `reconcilekit`; adds a `Repo`/`RepoResult` shape and a couple of CLI
6
+ conveniences (`run_cli`, `as_set`) on top. No config-file loading, no sops,
7
+ no fork/exclude policy -- those stay with the caller, which is why
8
+ `list_repos`'s `include_forks` defaults to none included rather than to any
9
+ file-backed default.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .core import (
15
+ DEFAULT_JOBS,
16
+ DEFAULT_OWNER,
17
+ Repo,
18
+ RepoResult,
19
+ as_set,
20
+ filter_repos,
21
+ list_repos,
22
+ run_cli,
23
+ run_parallel,
24
+ )
25
+ from .email import send_email, send_email_from_env
26
+
27
+ __all__ = [
28
+ "DEFAULT_JOBS",
29
+ "DEFAULT_OWNER",
30
+ "Repo",
31
+ "RepoResult",
32
+ "as_set",
33
+ "filter_repos",
34
+ "list_repos",
35
+ "run_cli",
36
+ "run_parallel",
37
+ "send_email",
38
+ "send_email_from_env",
39
+ ]
@@ -0,0 +1,142 @@
1
+ """Repo listing/filtering and the CLI-entrypoint plumbing shared by
2
+ repo-admin's scripts.
3
+
4
+ Fetches repos via `asyncgh.fetch_repos` and runs bounded-parallel work over
5
+ them via `reconcilekit.run_parallel` -- this module adds nothing beyond
6
+ that except the `Repo`/`RepoResult` shapes and a couple of CLI conveniences
7
+ (`run_cli`, `as_set`). Config-file-backed policy (which forks to include,
8
+ which repos to exclude from a specific check, secrets) is a caller concern,
9
+ not this package's -- `include_forks` here is just a plain set, with no
10
+ default derived from any file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import sys
18
+ from collections.abc import Awaitable, Callable
19
+ from dataclasses import dataclass
20
+ from typing import TypeVar, cast
21
+
22
+ from asyncgh import GhError, aclose_client, fetch_repos
23
+ from reconcilekit import Status
24
+ from reconcilekit import run_parallel as _run_parallel
25
+
26
+ DEFAULT_OWNER = os.environ.get("GH_OWNER", "hugoh")
27
+ DEFAULT_JOBS = int(os.environ.get("GH_JOBS", "6"))
28
+
29
+ _Args = TypeVar("_Args")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Repo:
34
+ name: str
35
+ default_branch: str
36
+ is_private: bool
37
+ is_fork: bool
38
+ homepage: str = ""
39
+
40
+
41
+ @dataclass
42
+ class RepoResult:
43
+ repo: Repo
44
+ line: str
45
+ status: Status = Status.OK
46
+ tag: str | None = None
47
+
48
+
49
+ def as_set(value: str | None) -> set[str] | None:
50
+ """Splits a comma-separated string into a set, or `None` if `value` is
51
+ falsy -- the CLI-argument convention every repo-admin script's
52
+ `--skip`/repo-list arguments share.
53
+ """
54
+ if not value:
55
+ return None
56
+ return {v.strip() for v in value.split(",") if v.strip()}
57
+
58
+
59
+ def filter_repos(
60
+ repos_json: list[dict],
61
+ *,
62
+ only: set[str] | None = None,
63
+ skip: set[str] | None = None,
64
+ include_forks: set[str] | None = None,
65
+ ) -> list[Repo]:
66
+ """Keeps every non-archived repo that's either not a fork or is listed in
67
+ include_forks, then applies only/skip.
68
+ """
69
+ include_forks = include_forks or set()
70
+ repos = []
71
+ for entry in repos_json:
72
+ if entry["archived"]:
73
+ continue
74
+ name = entry["name"]
75
+ if entry["fork"] and name not in include_forks:
76
+ continue
77
+ if only and name not in only:
78
+ continue
79
+ if skip and name in skip:
80
+ continue
81
+ repos.append(
82
+ Repo(
83
+ name=name,
84
+ default_branch=entry.get("default_branch") or "",
85
+ is_private=entry["private"],
86
+ is_fork=entry["fork"],
87
+ homepage=entry.get("homepage") or "",
88
+ )
89
+ )
90
+ return repos
91
+
92
+
93
+ async def list_repos(
94
+ owner: str = DEFAULT_OWNER,
95
+ *,
96
+ only: set[str] | None = None,
97
+ skip: set[str] | None = None,
98
+ include_forks: set[str] | None = None,
99
+ ) -> list[Repo]:
100
+ """Fetches every repo for `owner` and applies `filter_repos`.
101
+ `include_forks` defaults to none included -- callers with a
102
+ file/env-backed fork policy (like repo-admin's) resolve it themselves
103
+ and pass it in.
104
+ """
105
+ # RepoJSON (a TypedDict) isn't assignable to plain dict per ty -- these
106
+ # helpers work on repo JSON generically, not asyncgh's specific shape.
107
+ repos_json = cast("list[dict]", await fetch_repos(owner))
108
+ return filter_repos(repos_json, only=only, skip=skip, include_forks=include_forks)
109
+
110
+
111
+ def run_cli(
112
+ entrypoint: Callable[[_Args], Awaitable[int]],
113
+ args: _Args,
114
+ ) -> int:
115
+ """Runs an async CLI entrypoint under asyncio, always closing the shared
116
+ HTTP client afterwards and turning a GhError into a stderr message plus
117
+ exit code 1 -- the wrapper every repo-admin-style script's main() shares.
118
+ """
119
+
120
+ async def _run() -> int:
121
+ try:
122
+ return await entrypoint(args)
123
+ finally:
124
+ await aclose_client()
125
+
126
+ try:
127
+ return asyncio.run(_run())
128
+ except GhError as exc:
129
+ print(exc, file=sys.stderr)
130
+ return 1
131
+
132
+
133
+ async def run_parallel(
134
+ repos: list[Repo], worker, *, jobs: int = DEFAULT_JOBS, verbose: bool = False
135
+ ) -> list[RepoResult]:
136
+ """reconcilekit.run_parallel with GhError bound as the failure exception
137
+ type. See reconcilekit.kernel for the concurrency, failure-isolation,
138
+ and quiet-suppression behaviour.
139
+ """
140
+ return await _run_parallel(
141
+ repos, worker, jobs=jobs, verbose=verbose, error_cls=GhError
142
+ )
@@ -0,0 +1,54 @@
1
+ """Sends a rendered HTML report via an SMTP relay.
2
+
3
+ Kept separate from repo-fetching concerns so a consumer can swap this for a
4
+ Slack post, a workflow artifact, or nothing at all, without touching how it
5
+ fetches or renders a report.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import smtplib
12
+ from email.mime.multipart import MIMEMultipart
13
+ from email.mime.text import MIMEText
14
+
15
+
16
+ def send_email(
17
+ html_body: str,
18
+ *,
19
+ smtp_host: str,
20
+ smtp_port: int,
21
+ smtp_user: str,
22
+ smtp_password: str,
23
+ from_addr: str,
24
+ to_addr: str,
25
+ subject: str,
26
+ ) -> None:
27
+ msg = MIMEMultipart("alternative")
28
+ msg["Subject"] = subject
29
+ msg["From"] = from_addr
30
+ msg["To"] = to_addr
31
+ msg.attach(MIMEText("This email requires an HTML-capable mail client.", "plain"))
32
+ msg.attach(MIMEText(html_body, "html"))
33
+
34
+ with smtplib.SMTP(smtp_host, smtp_port) as server:
35
+ server.starttls()
36
+ server.login(smtp_user, smtp_password)
37
+ server.send_message(msg)
38
+
39
+
40
+ def send_email_from_env(html_body: str, *, subject: str) -> None:
41
+ """send_email(), reading the relay and recipient from the environment:
42
+ SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, DIGEST_FROM_EMAIL,
43
+ DIGEST_TO_EMAIL.
44
+ """
45
+ send_email(
46
+ html_body,
47
+ smtp_host=os.environ["SMTP_HOST"],
48
+ smtp_port=int(os.environ["SMTP_PORT"]),
49
+ smtp_user=os.environ["SMTP_USERNAME"],
50
+ smtp_password=os.environ["SMTP_PASSWORD"],
51
+ from_addr=os.environ["DIGEST_FROM_EMAIL"],
52
+ to_addr=os.environ["DIGEST_TO_EMAIL"],
53
+ subject=subject,
54
+ )
File without changes
@@ -0,0 +1,148 @@
1
+ import pytest
2
+
3
+ from asyncgh import GhError
4
+ from reconcilekit import Status
5
+ from repokit import Repo, RepoResult, filter_repos, run_cli
6
+
7
+ REPOS_JSON = [
8
+ {
9
+ "name": "public-repo",
10
+ "fork": False,
11
+ "archived": False,
12
+ "private": False,
13
+ "default_branch": "main",
14
+ },
15
+ {
16
+ "name": "archived-repo",
17
+ "fork": False,
18
+ "archived": True,
19
+ "private": False,
20
+ "default_branch": "main",
21
+ },
22
+ {
23
+ "name": "fork-repo",
24
+ "fork": True,
25
+ "archived": False,
26
+ "private": False,
27
+ "default_branch": "main",
28
+ },
29
+ {
30
+ "name": "maintained-fork",
31
+ "fork": True,
32
+ "archived": False,
33
+ "private": True,
34
+ "default_branch": "master",
35
+ },
36
+ {
37
+ "name": "empty-repo",
38
+ "fork": False,
39
+ "archived": False,
40
+ "private": False,
41
+ "default_branch": None,
42
+ },
43
+ ]
44
+
45
+
46
+ def test_filter_repos_excludes_archived():
47
+ repos = filter_repos(REPOS_JSON)
48
+ assert "archived-repo" not in [r.name for r in repos]
49
+
50
+
51
+ def test_filter_repos_excludes_forks_by_default():
52
+ repos = filter_repos(REPOS_JSON)
53
+ assert "fork-repo" not in [r.name for r in repos]
54
+ assert "maintained-fork" not in [r.name for r in repos]
55
+
56
+
57
+ def test_filter_repos_includes_listed_forks():
58
+ repos = filter_repos(REPOS_JSON, include_forks={"maintained-fork"})
59
+ names = [r.name for r in repos]
60
+ assert "maintained-fork" in names
61
+ assert "fork-repo" not in names
62
+
63
+
64
+ def test_filter_repos_only_filter():
65
+ repos = filter_repos(REPOS_JSON, only={"public-repo"})
66
+ assert [r.name for r in repos] == ["public-repo"]
67
+
68
+
69
+ def test_filter_repos_skip_filter():
70
+ repos = filter_repos(REPOS_JSON, skip={"public-repo"})
71
+ assert "public-repo" not in [r.name for r in repos]
72
+
73
+
74
+ def test_filter_repos_fields():
75
+ repos = filter_repos(REPOS_JSON, only={"public-repo"})
76
+ assert repos == [
77
+ Repo(name="public-repo", default_branch="main", is_private=False, is_fork=False)
78
+ ]
79
+
80
+
81
+ def test_filter_repos_reads_homepage():
82
+ repos_json = [
83
+ {
84
+ "name": "site-repo",
85
+ "fork": False,
86
+ "archived": False,
87
+ "private": False,
88
+ "default_branch": "main",
89
+ "homepage": "https://example.com",
90
+ }
91
+ ]
92
+ assert filter_repos(repos_json)[0].homepage == "https://example.com"
93
+
94
+
95
+ def test_filter_repos_homepage_defaults_to_empty_when_null():
96
+ repos = filter_repos(REPOS_JSON, only={"public-repo"})
97
+ assert repos[0].homepage == ""
98
+
99
+
100
+ def test_filter_repos_handles_null_default_branch():
101
+ repos = filter_repos(REPOS_JSON, only={"empty-repo"})
102
+ assert repos[0].default_branch == ""
103
+
104
+
105
+ def test_repo_result_defaults_to_ok_status():
106
+ repo = Repo(name="repo", default_branch="main", is_private=False, is_fork=False)
107
+ assert RepoResult(repo, "line").status == Status.OK
108
+
109
+
110
+ def test_run_cli_runs_entrypoint_and_closes_client(monkeypatch):
111
+ closed = []
112
+ monkeypatch.setattr("repokit.core.aclose_client", _record_close(closed))
113
+
114
+ async def entrypoint(args):
115
+ assert args == "ARGS"
116
+ return 0
117
+
118
+ assert run_cli(entrypoint, "ARGS") == 0
119
+ assert closed == [True]
120
+
121
+
122
+ def test_run_cli_closes_client_even_when_entrypoint_raises(monkeypatch):
123
+ closed = []
124
+ monkeypatch.setattr("repokit.core.aclose_client", _record_close(closed))
125
+
126
+ async def entrypoint(args):
127
+ raise RuntimeError("boom")
128
+
129
+ with pytest.raises(RuntimeError, match="boom"):
130
+ run_cli(entrypoint, None)
131
+ assert closed == [True]
132
+
133
+
134
+ def test_run_cli_converts_gh_error_to_exit_code_1(monkeypatch, capsys):
135
+ monkeypatch.setattr("repokit.core.aclose_client", _record_close([]))
136
+
137
+ async def entrypoint(args):
138
+ raise GhError("nope")
139
+
140
+ assert run_cli(entrypoint, None) == 1
141
+ assert "nope" in capsys.readouterr().err
142
+
143
+
144
+ def _record_close(sink):
145
+ async def _aclose():
146
+ sink.append(True)
147
+
148
+ return _aclose
@@ -0,0 +1,121 @@
1
+ from email import message_from_bytes
2
+ from typing import ClassVar
3
+
4
+ import pytest
5
+ from repokit.email import send_email, send_email_from_env
6
+
7
+
8
+ class FakeSMTP:
9
+ instances: ClassVar[list] = []
10
+
11
+ def __init__(self, host, port):
12
+ self.host = host
13
+ self.port = port
14
+ self.calls = []
15
+ FakeSMTP.instances.append(self)
16
+
17
+ def __enter__(self):
18
+ return self
19
+
20
+ def __exit__(self, *exc_info):
21
+ return False
22
+
23
+ def starttls(self):
24
+ self.calls.append("starttls")
25
+
26
+ def login(self, user, password):
27
+ self.calls.append(("login", user, password))
28
+
29
+ def send_message(self, msg):
30
+ self.calls.append(("send_message", msg))
31
+
32
+
33
+ @pytest.fixture(autouse=True)
34
+ def fake_smtp(monkeypatch):
35
+ FakeSMTP.instances = []
36
+ monkeypatch.setattr("smtplib.SMTP", FakeSMTP)
37
+ return FakeSMTP
38
+
39
+
40
+ def test_send_email_starttls_login_and_sends(fake_smtp):
41
+ send_email(
42
+ "<html>hi</html>",
43
+ smtp_host="smtp.example.com",
44
+ smtp_port=587,
45
+ smtp_user="user",
46
+ smtp_password="pass",
47
+ from_addr="from@example.com",
48
+ to_addr="to@example.com",
49
+ subject="PR digest",
50
+ )
51
+ (instance,) = fake_smtp.instances
52
+ assert instance.host == "smtp.example.com"
53
+ assert instance.port == 587
54
+ assert instance.calls[0] == "starttls"
55
+ assert instance.calls[1] == ("login", "user", "pass")
56
+ assert instance.calls[2][0] == "send_message"
57
+
58
+
59
+ def test_send_email_message_has_plain_and_html_parts(fake_smtp):
60
+ send_email(
61
+ "<html><body>hi</body></html>",
62
+ smtp_host="smtp.example.com",
63
+ smtp_port=587,
64
+ smtp_user="user",
65
+ smtp_password="pass",
66
+ from_addr="from@example.com",
67
+ to_addr="to@example.com",
68
+ subject="PR digest",
69
+ )
70
+ (instance,) = fake_smtp.instances
71
+ msg = instance.calls[2][1]
72
+ parsed = message_from_bytes(msg.as_bytes())
73
+ content_types = {part.get_content_type() for part in parsed.walk()}
74
+ assert "text/plain" in content_types
75
+ assert "text/html" in content_types
76
+
77
+
78
+ def test_send_email_sets_subject_and_addresses(fake_smtp):
79
+ send_email(
80
+ "<html>hi</html>",
81
+ smtp_host="smtp.example.com",
82
+ smtp_port=587,
83
+ smtp_user="user",
84
+ smtp_password="pass",
85
+ from_addr="from@example.com",
86
+ to_addr="to@example.com",
87
+ subject="PR digest",
88
+ )
89
+ (instance,) = fake_smtp.instances
90
+ msg = instance.calls[2][1]
91
+ assert msg["Subject"] == "PR digest"
92
+ assert msg["From"] == "from@example.com"
93
+ assert msg["To"] == "to@example.com"
94
+
95
+
96
+ def test_send_email_from_env_reads_smtp_settings_from_environment(
97
+ fake_smtp, monkeypatch
98
+ ):
99
+ monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
100
+ monkeypatch.setenv("SMTP_PORT", "587")
101
+ monkeypatch.setenv("SMTP_USERNAME", "user")
102
+ monkeypatch.setenv("SMTP_PASSWORD", "pass")
103
+ monkeypatch.setenv("DIGEST_FROM_EMAIL", "from@example.com")
104
+ monkeypatch.setenv("DIGEST_TO_EMAIL", "to@example.com")
105
+
106
+ send_email_from_env("<html>hi</html>", subject="PR digest")
107
+
108
+ (instance,) = fake_smtp.instances
109
+ assert instance.host == "smtp.example.com"
110
+ assert instance.port == 587
111
+ assert instance.calls[1] == ("login", "user", "pass")
112
+ msg = instance.calls[2][1]
113
+ assert msg["Subject"] == "PR digest"
114
+ assert msg["From"] == "from@example.com"
115
+ assert msg["To"] == "to@example.com"
116
+
117
+
118
+ def test_send_email_from_env_raises_on_missing_env_var(fake_smtp, monkeypatch):
119
+ monkeypatch.delenv("SMTP_HOST", raising=False)
120
+ with pytest.raises(KeyError):
121
+ send_email_from_env("<html>hi</html>", subject="PR digest")