nocturne-shift 0.5.5__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.
- nocturne_shift-0.5.5/.claude/skills/pytest-review/SKILL.md +140 -0
- nocturne_shift-0.5.5/.claude/skills/pytest-review/references/mock-pitfalls.md +108 -0
- nocturne_shift-0.5.5/.claude/skills/pytest-review/references/pytest-rosetta.md +36 -0
- nocturne_shift-0.5.5/.claude/skills/pytest-review/scripts/mutation_check.py +69 -0
- nocturne_shift-0.5.5/.github/workflows/publish-pypi.yml +119 -0
- nocturne_shift-0.5.5/.github/workflows/publish-testpypi.yml +106 -0
- nocturne_shift-0.5.5/.github/workflows/test.yml +41 -0
- nocturne_shift-0.5.5/.gitignore +223 -0
- nocturne_shift-0.5.5/LICENSE +674 -0
- nocturne_shift-0.5.5/Makefile +70 -0
- nocturne_shift-0.5.5/PKG-INFO +113 -0
- nocturne_shift-0.5.5/README.md +86 -0
- nocturne_shift-0.5.5/docs/README.md +5 -0
- nocturne_shift-0.5.5/pyproject.toml +62 -0
- nocturne_shift-0.5.5/pytest.ini +5 -0
- nocturne_shift-0.5.5/setup.cfg +4 -0
- nocturne_shift-0.5.5/src/nocturne_shift/__init__.py +2 -0
- nocturne_shift-0.5.5/src/nocturne_shift/bin/get_sunrise_sunset.py +241 -0
- nocturne_shift-0.5.5/src/nocturne_shift/bin/is_day_or_night.py +59 -0
- nocturne_shift-0.5.5/src/nocturne_shift/bin/settings.py +53 -0
- nocturne_shift-0.5.5/src/nocturne_shift/cli.py +217 -0
- nocturne_shift-0.5.5/src/nocturne_shift/data_store.py +120 -0
- nocturne_shift-0.5.5/src/nocturne_shift/schemas/com.github.christophermca.night-shift.gschema.xml +38 -0
- nocturne_shift-0.5.5/src/nocturne_shift/schemas/gschemas.compiled +0 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/PKG-INFO +113 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/SOURCES.txt +38 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/dependency_links.txt +1 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/entry_points.txt +3 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/requires.txt +15 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/scm_file_list.json +34 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/scm_version.json +8 -0
- nocturne_shift-0.5.5/src/nocturne_shift.egg-info/top_level.txt +1 -0
- nocturne_shift-0.5.5/tests/.gitignore +1 -0
- nocturne_shift-0.5.5/tests/conftest.py +61 -0
- nocturne_shift-0.5.5/tests/test_cli.py +301 -0
- nocturne_shift-0.5.5/tests/test_data_store.py +109 -0
- nocturne_shift-0.5.5/tests/test_get_sunrise_sunset.py +251 -0
- nocturne_shift-0.5.5/tests/test_is_day_or_night.py +106 -0
- nocturne_shift-0.5.5/tests/test_plugins.py +75 -0
- nocturne_shift-0.5.5/tests/test_settings.py +72 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pytest-review
|
|
3
|
+
description: Review or write Python tests (pytest + unittest.mock) so they actually catch bugs, not just go green. Use this whenever the user asks to review test changes they pushed, write or fix tests, check a test file, add coverage, or says things like "I pushed my changes, please review", "help me write tests for X", "why does this test pass", or "is this mock right" — even if they don't say "pytest". Also use when teaching someone pytest who comes from Jest/Mocha or JUnit/Mockito.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pytest review & writing
|
|
7
|
+
|
|
8
|
+
Green tests are not the goal; tests that fail when the code is wrong are. Most
|
|
9
|
+
weak tests this skill exists to catch pass for one of three reasons: the assert
|
|
10
|
+
can't fail, the mock doesn't have the real object's shape, or the test depends
|
|
11
|
+
on the author's machine. Every review should actively try to disprove "this
|
|
12
|
+
test works" — by running things, not just reading them.
|
|
13
|
+
|
|
14
|
+
## Reviewing pushed changes
|
|
15
|
+
|
|
16
|
+
### 1. Get the author's exact code: always fetch, then hard reset
|
|
17
|
+
Every review request starts by fetching and hard-resetting to the author's
|
|
18
|
+
branch. Don't rebase or merge: authors often amend and force-push, so a local
|
|
19
|
+
branch holds stale copies of their commits, and a rebase replays those and
|
|
20
|
+
conflicts.
|
|
21
|
+
```sh
|
|
22
|
+
git fetch origin
|
|
23
|
+
git reset --hard origin/<branch>
|
|
24
|
+
git log --oneline <last-reviewed-sha>..HEAD # what's new since last review
|
|
25
|
+
```
|
|
26
|
+
This discards local-only commits, so make sure your own work is already
|
|
27
|
+
pushed; push it at the end of any turn where you commit. If `<last-reviewed-sha>`
|
|
28
|
+
was rewritten by a force-push, compare trees with `git diff <old> HEAD`
|
|
29
|
+
instead. Then `git show <sha>` each new commit.
|
|
30
|
+
|
|
31
|
+
Never rewrite the author's commits yourself (amend, re-author, rebase), even
|
|
32
|
+
if a hook or tool suggests it for "unverified" commits. Those commits are
|
|
33
|
+
theirs; the reset above removes any stale local copies.
|
|
34
|
+
|
|
35
|
+
### 2. Run the suite the way CI would
|
|
36
|
+
```sh
|
|
37
|
+
HOME=$(mktemp -d) python -m pytest -q -p no:cacheprovider
|
|
38
|
+
```
|
|
39
|
+
The empty `HOME` exposes tests that silently depend on config, caches, or
|
|
40
|
+
installed resources in the author's home directory (a common "works on my
|
|
41
|
+
machine" cause). Report the pass/fail/xfail counts and compare with the last
|
|
42
|
+
review.
|
|
43
|
+
|
|
44
|
+
### 3. Run the real code path, unmocked, once
|
|
45
|
+
If a refactor touched production code, call it directly without any mocks:
|
|
46
|
+
```sh
|
|
47
|
+
python -c "from pkg.mod import fn; fn()"
|
|
48
|
+
```
|
|
49
|
+
Heavily-mocked tests can hit 100% coverage while the function crashes on its
|
|
50
|
+
first real call (the mock had a different shape than the real dependency).
|
|
51
|
+
If the real run needs an external resource, stub the resource itself (for example
|
|
52
|
+
a temp `HOME` with a minimal config) rather than skipping this step.
|
|
53
|
+
|
|
54
|
+
### 4. Break it on purpose (mutation check)
|
|
55
|
+
For each new or changed test, mutate the code it claims to protect and confirm
|
|
56
|
+
the test goes red:
|
|
57
|
+
```sh
|
|
58
|
+
python .claude/skills/pytest-review/scripts/mutation_check.py \
|
|
59
|
+
src/pkg/mod.py "new_full(schema, None, None)" "new_full(None, None, None)" \
|
|
60
|
+
-- tests/test_mod.py -k builds
|
|
61
|
+
```
|
|
62
|
+
The script applies the edit, runs pytest, always restores the file, and says
|
|
63
|
+
whether the mutant was caught. A test that stays green against a plausible bug
|
|
64
|
+
is the most important finding a review can make — lead with it.
|
|
65
|
+
|
|
66
|
+
### 5. Read for the known traps
|
|
67
|
+
Go through `references/mock-pitfalls.md`. Each entry has a symptom to grep for
|
|
68
|
+
and the fix. The recurring ones:
|
|
69
|
+
- asserting on an auto-created mock attribute (`result.return_value`, `assert mock`)
|
|
70
|
+
- mock shape ≠ real shape (`Cls()` vs `Cls()()`)
|
|
71
|
+
- plain `MagicMock` accepting method names the real class lacks → use `spec`/`create_autospec`
|
|
72
|
+
- patching where defined instead of where looked up
|
|
73
|
+
- stacked/class-level `@patch` argument order silently mislabelling mocks
|
|
74
|
+
- tests that patch only part of an external resource and still touch disk/network
|
|
75
|
+
|
|
76
|
+
### 6. Check for stale references
|
|
77
|
+
When tests are renamed/deleted or bugs get fixed, comments and docs rot:
|
|
78
|
+
```sh
|
|
79
|
+
git grep -n -E "<old test name>|<removed function>|BUG:|xfail" -- docs tests
|
|
80
|
+
```
|
|
81
|
+
Also check that fixed bugs lost their `xfail` markers and that any "this is a
|
|
82
|
+
known bug" comments were updated.
|
|
83
|
+
|
|
84
|
+
### 7. Write the review
|
|
85
|
+
Use this structure; keep it scannable:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
**Verdict:** one line — e.g. "Real fix, but test X can't fail."
|
|
89
|
+
**What I ran:** suite result (and env), real-path result, mutation results.
|
|
90
|
+
|
|
91
|
+
## ✅ Done well (brief, specific — reinforce good habits)
|
|
92
|
+
## [high] Title — what's wrong, evidence (actual output), the fix as code,
|
|
93
|
+
and the one-line lesson
|
|
94
|
+
## [low] Title — tidy-ups, grouped
|
|
95
|
+
## Next — what to fix first / next exercise
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Rules of thumb:
|
|
99
|
+
- Every high finding carries evidence you produced (a traceback, a mutation
|
|
100
|
+
that stayed green) — not "this might fail".
|
|
101
|
+
- Give the fix as a code snippet, and say *why* it works.
|
|
102
|
+
- If a previous finding turns out wrong or overstated, say so and downgrade it.
|
|
103
|
+
- If you track findings across rounds, show a small status table (✅ / ❌ / open).
|
|
104
|
+
- Don't push fixes to the author's branch unless asked; when the user is
|
|
105
|
+
learning, offer to let them make the change.
|
|
106
|
+
|
|
107
|
+
## Writing tests
|
|
108
|
+
|
|
109
|
+
1. **State the promise first.** Before any assert, write down what the unit
|
|
110
|
+
promises ("returns what `new_full` built, with the looked-up schema"). One
|
|
111
|
+
assert per promise.
|
|
112
|
+
2. **Fake at the edge, run the middle.** Mock network, clock, subprocesses, OS
|
|
113
|
+
services, UI toolkits. Don't mock your own logic; use `tmp_path` for real
|
|
114
|
+
files. Patch the outermost boundary you can (`requests.get`, not your own
|
|
115
|
+
`_fetch`) so more real code runs.
|
|
116
|
+
3. **Match the real shape.** Mirror every call level the code uses
|
|
117
|
+
(`Cls.return_value.return_value` for `Cls()()`), or use `create_autospec`
|
|
118
|
+
so a wrong shape raises.
|
|
119
|
+
4. **Assert identity/values, not truthiness.** `assert result is
|
|
120
|
+
mock.return_value`, `assert_called_once_with(...)`, `call_args.args[0]`.
|
|
121
|
+
5. **Pin non-determinism.** Patch `datetime`, sort `os.scandir` results, never
|
|
122
|
+
depend on `HOME` or test order; reset module-level caches with
|
|
123
|
+
`monkeypatch.setattr(mod, "_cache", None)`.
|
|
124
|
+
6. **Boundaries & branches.** Test both sides of each threshold (05:59/06:00)
|
|
125
|
+
and each `if`/`except` branch; use `@pytest.mark.parametrize` rather than
|
|
126
|
+
several cases in one test body (first failure hides the rest).
|
|
127
|
+
7. **Known bugs → strict xfail.** Write the test for the *correct* behaviour
|
|
128
|
+
and mark it `@pytest.mark.xfail(strict=True, raises=<Exc>, reason="BUG: …")`.
|
|
129
|
+
`raises=` stops it "passing" as xfail for the wrong reason; `strict` makes
|
|
130
|
+
the eventual fix show up as XPASS so the marker gets removed. Verify each
|
|
131
|
+
xfail fails for the named reason with `pytest --runxfail`.
|
|
132
|
+
8. **Then run steps 2–4 above on your own tests.**
|
|
133
|
+
|
|
134
|
+
## Teaching mode
|
|
135
|
+
|
|
136
|
+
If the user is learning (asks to be taught, mentions another stack), map each
|
|
137
|
+
concept to what they know — see `references/pytest-rosetta.md` for pytest ↔
|
|
138
|
+
Jest ↔ JUnit/Mockito equivalents — and explain the *why* at the point of use.
|
|
139
|
+
Prefer letting them write the fix after the review; give a checklist with a
|
|
140
|
+
self-verification command (`HOME=$(mktemp -d) …`, the mutation check).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# unittest.mock pitfalls that make tests lie
|
|
2
|
+
|
|
3
|
+
Each entry: **symptom** (what to look for), **why** it lies, **detect**, **fix**.
|
|
4
|
+
Examples are real ones found while reviewing the night-shift repo.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
1. Asserting on auto-created attributes
|
|
8
|
+
2. Mock shape ≠ real shape
|
|
9
|
+
3. Loose MagicMock hides missing methods
|
|
10
|
+
4. Patching where defined, not where looked up
|
|
11
|
+
5. Stacked / class-level `@patch` argument order
|
|
12
|
+
6. Partial patching still touches the machine
|
|
13
|
+
7. Module-level caches leak between tests
|
|
14
|
+
8. Smoke tests and tests that can't reach the code
|
|
15
|
+
9. xfail that fails for the wrong reason
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Asserting on auto-created attributes
|
|
20
|
+
**Symptom:** `assert something.return_value`, `assert mock.foo`, `assert result`
|
|
21
|
+
where `result` came from a mock.
|
|
22
|
+
**Why:** A `MagicMock` creates any attribute on access and it is always truthy.
|
|
23
|
+
`module._settings().return_value` is a brand-new MagicMock, so
|
|
24
|
+
`assert settings` passes no matter what `_settings()` did.
|
|
25
|
+
**Detect:** Mutation check — change the production call's arguments; the test stays green.
|
|
26
|
+
**Fix:** Assert identity and arguments:
|
|
27
|
+
```python
|
|
28
|
+
settings = module._settings()
|
|
29
|
+
assert settings is mock_new_full.return_value
|
|
30
|
+
mock_new_full.assert_called_once_with(source.lookup.return_value, None, None)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 2. Mock shape ≠ real shape
|
|
34
|
+
**Symptom:** Production does `Settings()()` (wrapper whose `__call__` returns the
|
|
35
|
+
real object) but the test sets `mock_cls.return_value = fake`.
|
|
36
|
+
**Why:** The test exercises code you didn't write. Here the production line was
|
|
37
|
+
`settings = Settings()` → `AttributeError: 'Settings' object has no attribute
|
|
38
|
+
'get_value'` on the first real run, while the suite was green at 100% coverage.
|
|
39
|
+
**Detect:** Run the real code path unmocked (SKILL.md step 3). `create_autospec(Settings)`
|
|
40
|
+
would also raise `Mock object has no attribute 'get_value'`.
|
|
41
|
+
**Fix:** Mirror each call level: `mock_cls.return_value.return_value = fake`,
|
|
42
|
+
or use `create_autospec(RealClass)` so a wrong shape raises.
|
|
43
|
+
|
|
44
|
+
## 3. Loose MagicMock hides missing methods
|
|
45
|
+
**Symptom:** Code calls a method name the real API doesn't have
|
|
46
|
+
(`settings.set_bool` — Gio only has `set_boolean`), or a check like
|
|
47
|
+
`callable(proc)` that is always True for a MagicMock but False for a real `Popen`.
|
|
48
|
+
**Why:** MagicMock accepts any name and is callable.
|
|
49
|
+
**Fix:** `MagicMock(spec=Gio.Settings)` restricts attributes to the real class.
|
|
50
|
+
For an *instance* use `create_autospec(Cls, instance=True)` — a spec taken from a
|
|
51
|
+
class is itself callable, so `MagicMock(spec=Popen)` still passes `callable()`.
|
|
52
|
+
If the test patches the very thing you want to spec (e.g. `subprocess.Popen`),
|
|
53
|
+
capture the real one at import time: `REAL_POPEN = subprocess.Popen`.
|
|
54
|
+
|
|
55
|
+
## 4. Patching where defined, not where looked up
|
|
56
|
+
**Symptom:** `@patch("pkg.bin.fetch.Fetcher")` but the code under test did
|
|
57
|
+
`from pkg.bin.fetch import Fetcher` in `pkg/__init__.py`.
|
|
58
|
+
**Why:** `patch` rebinds one name in one namespace; the importer already holds
|
|
59
|
+
its own reference. (Unlike `jest.mock`, which swaps the module in the registry.)
|
|
60
|
+
**Fix:** Patch `<module that uses it>.<name it uses>` — e.g. `pkg.Fetcher`.
|
|
61
|
+
For `import requests` + `requests.get(...)`, patch `pkg.mod.requests.get`.
|
|
62
|
+
|
|
63
|
+
## 5. Stacked / class-level `@patch` argument order
|
|
64
|
+
**Symptom:** A test class decorated with two `@patch`es whose methods name only
|
|
65
|
+
one mock, or name them in the wrong order.
|
|
66
|
+
**Why:** Decorators apply bottom-up → the **bottom** patch is the **first**
|
|
67
|
+
argument after `self`. *Every* method receives all patch mocks positionally,
|
|
68
|
+
regardless of parameter names; fixtures are injected by name only *after* them.
|
|
69
|
+
So `def test_x(self, mock_a, mock_settings)` may bind `mock_settings` to the
|
|
70
|
+
second patch, silently shadowing the fixture.
|
|
71
|
+
**Detect:** Print the mocks' `_mock_name`, or assert on them.
|
|
72
|
+
**Fix:** Name all patch args in bottom-up order, or prefer per-test `with patch(...)`.
|
|
73
|
+
|
|
74
|
+
## 6. Partial patching still touches the machine
|
|
75
|
+
**Symptom:** Only the last call in a chain is patched
|
|
76
|
+
(`@patch("...Gio.Settings.new_full")`) while earlier calls
|
|
77
|
+
(`Gio.SettingsSchemaSource.new_from_directory(~/.local/...)`) run for real.
|
|
78
|
+
**Why:** Passes on the author's machine (resource installed), fails in CI/fresh
|
|
79
|
+
clone: `GLib.Error: Failed to open file …/gschemas.compiled`.
|
|
80
|
+
**Detect:** `HOME=$(mktemp -d) python -m pytest`.
|
|
81
|
+
**Fix:** Patch at the seam that owns the resource (`_get_schema_source`, the
|
|
82
|
+
module's `Gio`, `requests.get`), or inject paths so tests can pass `tmp_path`.
|
|
83
|
+
|
|
84
|
+
## 7. Module-level caches leak between tests
|
|
85
|
+
**Symptom:** A module global like `_schema_source = None` filled lazily.
|
|
86
|
+
**Why:** The first test to run fills it; later tests reuse a real (or stale mock)
|
|
87
|
+
object → order-dependent results.
|
|
88
|
+
**Fix:** `monkeypatch.setattr(module, "_cache", None)` in the test/fixture
|
|
89
|
+
(restored automatically). Check by running the file in a different order.
|
|
90
|
+
|
|
91
|
+
## 8. Smoke tests and tests that can't reach the code
|
|
92
|
+
**Symptom:** No assert; or an error injected into a mock that the chosen
|
|
93
|
+
arguments never call (e.g. `side_effect = HTTPError` on `_get_location` while
|
|
94
|
+
`use_geoclue=False`, and the HTTP method itself is mocked out).
|
|
95
|
+
**Why:** Passes forever, proves nothing.
|
|
96
|
+
**Fix:** Inject the failure where the real one originates
|
|
97
|
+
(`response.raise_for_status.side_effect = HTTPError(...)`) and assert the
|
|
98
|
+
observable outcome (`returns None`, `set_value.assert_not_called()`).
|
|
99
|
+
|
|
100
|
+
## 9. xfail that fails for the wrong reason
|
|
101
|
+
**Symptom:** `@pytest.mark.xfail` without `raises=`, or a mock set up so the test
|
|
102
|
+
errors before reaching the buggy line.
|
|
103
|
+
**Why:** Shows as the expected `x` even though it's not exercising the bug.
|
|
104
|
+
**Detect:** `pytest --runxfail -q | grep "^E "` — each failure message should name the bug.
|
|
105
|
+
**Fix:** Add `strict=True` and `raises=<ExpectedExc>`; fix the setup until the
|
|
106
|
+
failure is the bug itself. When a strict xfail XPASSes, either the bug is fixed
|
|
107
|
+
(remove the marker) or your hypothesis was wrong (turn it into a normal
|
|
108
|
+
regression test).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pytest ↔ Jest ↔ JUnit 5 / Mockito
|
|
2
|
+
|
|
3
|
+
Use when teaching someone who knows JS or Java testing.
|
|
4
|
+
|
|
5
|
+
| Concept | pytest / unittest.mock | Jest | JUnit 5 + Mockito |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| Test discovery | `test_*.py`, `def test_*` | `*.test.js`, `test()` | `@Test` |
|
|
8
|
+
| Grouping | `class Test*` (no `__init__`) | `describe()` | test class / `@Nested` |
|
|
9
|
+
| Assert | `assert x == y` | `expect(x).toBe(y)` | `assertEquals(y, x)` |
|
|
10
|
+
| Identity | `assert a is b` | `toBe` on objects | `assertSame` |
|
|
11
|
+
| Expect exception | `with pytest.raises(E, match="re"):` | `expect(fn).toThrow()` | `assertThrows` |
|
|
12
|
+
| Setup/teardown | fixture with `yield` | `beforeEach`/`afterEach` | `@BeforeEach`/`@AfterEach` |
|
|
13
|
+
| Always-on setup | `@pytest.fixture(autouse=True)` | top-level `beforeEach` | base-class `@BeforeEach` |
|
|
14
|
+
| Shared across files | `tests/conftest.py` | `setupFilesAfterEnv` | base class / extension |
|
|
15
|
+
| Dependency injection | fixture name as parameter | — | constructor/extension injection |
|
|
16
|
+
| Fake object | `MagicMock()` | `jest.fn()` | `mock(Foo.class)` |
|
|
17
|
+
| Replace a dependency | `patch("mod.name")` | `jest.mock("mod")` | `@Mock` + `@InjectMocks` |
|
|
18
|
+
| Stub return | `m.return_value = x` | `mockReturnValue(x)` | `when(...).thenReturn(x)` |
|
|
19
|
+
| Stub sequence | `m.side_effect = [a, b]` | `mockReturnValueOnce` chain | `thenReturn(a, b)` |
|
|
20
|
+
| Stub throw | `m.side_effect = Exc()` | `mockImplementation(() => {throw})` | `thenThrow` |
|
|
21
|
+
| Stub with logic | `m.side_effect = fn` | `mockImplementation(fn)` | `thenAnswer` |
|
|
22
|
+
| Verify call | `assert_called_once_with(a)` | `toHaveBeenCalledWith(a)` | `verify(m).f(a)` |
|
|
23
|
+
| Capture arg | `m.call_args.args[0]` | `m.mock.calls[0][0]` | `ArgumentCaptor` |
|
|
24
|
+
| Call order | `m.call_args_list[0] == call(...)` | `mock.calls[0]` | `InOrder` |
|
|
25
|
+
| Type-safe mock | `spec=` / `create_autospec` | TS types | built in |
|
|
26
|
+
| Table-driven | `@pytest.mark.parametrize` | `test.each` | `@ParameterizedTest` |
|
|
27
|
+
| Known-broken | `xfail(strict=True)` | `test.failing` | — |
|
|
28
|
+
| Temp dir | `tmp_path` | manual `mkdtemp` | `@TempDir` |
|
|
29
|
+
| Capture stdout | `capsys` | spy on `console.log` | redirect `System.out` |
|
|
30
|
+
| Temp global/env change | `monkeypatch` | manual restore | `@SetEnvironmentVariable` (ext) |
|
|
31
|
+
| Fake clock | patch `mod.datetime` | `jest.useFakeTimers()` | inject `Clock` |
|
|
32
|
+
|
|
33
|
+
Biggest conceptual difference to call out: `patch` replaces **one name in one
|
|
34
|
+
namespace at runtime** (patch where it's looked up), whereas `jest.mock` swaps
|
|
35
|
+
the whole module before import. And Python mocks are untyped — without `spec`
|
|
36
|
+
they accept anything, which Java's compiler would have rejected.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Break the code on purpose and check that the tests notice.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
mutation_check.py FILE OLD NEW [-- PYTEST_ARGS...]
|
|
6
|
+
|
|
7
|
+
Replaces OLD (which must occur exactly once) with NEW in FILE, runs pytest (with
|
|
8
|
+
PYTEST_ARGS, e.g. a test path and -k filter), then always restores FILE.
|
|
9
|
+
|
|
10
|
+
Exit status: 0 if the mutant was caught (pytest failed), 1 if it survived
|
|
11
|
+
(tests stayed green: they don't protect this code), 2 on usage errors or if pytest itself errors.
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv):
|
|
20
|
+
if "--" in argv:
|
|
21
|
+
split = argv.index("--")
|
|
22
|
+
args, pytest_args = argv[:split], argv[split + 1 :]
|
|
23
|
+
else:
|
|
24
|
+
args, pytest_args = argv, []
|
|
25
|
+
|
|
26
|
+
if len(args) != 3:
|
|
27
|
+
print(__doc__, file=sys.stderr)
|
|
28
|
+
return 2
|
|
29
|
+
|
|
30
|
+
path, old, new = Path(args[0]), args[1], args[2]
|
|
31
|
+
original = path.read_text()
|
|
32
|
+
count = original.count(old)
|
|
33
|
+
if count != 1:
|
|
34
|
+
# A non-unique OLD would silently mutate whichever copy comes first,
|
|
35
|
+
# possibly in a different function than the one under review.
|
|
36
|
+
problem = "not found" if count == 0 else f"found {count} times"
|
|
37
|
+
print(f"'{old}' {problem} in {path}; include more context "
|
|
38
|
+
"(e.g. the preceding line, joined with a newline) so it "
|
|
39
|
+
"matches exactly once", file=sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
|
|
43
|
+
# `-o addopts=` drops ini addopts (e.g. --cov) so plugins the project
|
|
44
|
+
# configures can't turn a run into a usage error.
|
|
45
|
+
cmd = [sys.executable, "-m", "pytest", "-q", "--no-header",
|
|
46
|
+
"-p", "no:cacheprovider", "-o", "addopts=", *pytest_args]
|
|
47
|
+
try:
|
|
48
|
+
path.write_text(original.replace(old, new, 1))
|
|
49
|
+
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
50
|
+
finally:
|
|
51
|
+
path.write_text(original)
|
|
52
|
+
|
|
53
|
+
lines = [l for l in result.stdout.splitlines() if l.strip()]
|
|
54
|
+
print(f"mutant: {path}: {old!r} -> {new!r}")
|
|
55
|
+
print(f"pytest: {lines[-1] if lines else '(no output)'}")
|
|
56
|
+
# pytest exit codes: 0 all passed, 1 tests failed, >=2 error/usage/no tests.
|
|
57
|
+
if result.returncode == 0:
|
|
58
|
+
print("SURVIVED - tests stayed green; they don't protect this code")
|
|
59
|
+
return 1
|
|
60
|
+
if result.returncode == 1:
|
|
61
|
+
print("CAUGHT - tests failed as they should")
|
|
62
|
+
return 0
|
|
63
|
+
print(f"ERROR - pytest exited {result.returncode}; mutant not evaluated")
|
|
64
|
+
print(result.stdout[-2000:] + result.stderr[-2000:], file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Run by hand: Actions tab -> "Publish to PyPI" -> Run workflow, on a commit
|
|
4
|
+
# tagged cli-vX.Y.Z (nocturne-shift)
|
|
5
|
+
# Uploads to PyPI are permanent, so:
|
|
6
|
+
# - only release versions are uploaded (no .devN / a / b / rc builds), and
|
|
7
|
+
# - the publish jobs use the `pypi` environment; give it required reviewers
|
|
8
|
+
# (Settings -> Environments) so every upload waits for your approval.
|
|
9
|
+
# Logs in with PyPI Trusted Publishing: add a pending publisher on pypi.org for
|
|
10
|
+
# nocturne-shift with repository christophermca/nocturne-shift, workflow
|
|
11
|
+
# `publish-pypi.yml` and environment `pypi`.
|
|
12
|
+
# gnome-night-shift is published from its own repo:
|
|
13
|
+
# https://github.com/christophermca/gnome-night-shift
|
|
14
|
+
on:
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
test:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
with:
|
|
23
|
+
fetch-depth: 0
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v7
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.12"
|
|
28
|
+
cache: pip
|
|
29
|
+
|
|
30
|
+
# PyGObject builds from source on the runner; it needs these headers.
|
|
31
|
+
# libglib2.0-bin provides glib-compile-schemas for the schema tests.
|
|
32
|
+
|
|
33
|
+
- name: Install system libraries
|
|
34
|
+
run: |
|
|
35
|
+
sudo apt-get update
|
|
36
|
+
sudo apt-get install -y libgirepository-2.0-dev libcairo2-dev pkg-config libglib2.0-bin
|
|
37
|
+
|
|
38
|
+
- name: Install dependencies
|
|
39
|
+
run: |
|
|
40
|
+
python -m pip install --upgrade pip
|
|
41
|
+
python -m pip install -e ".[test]"
|
|
42
|
+
|
|
43
|
+
- name: Run tests
|
|
44
|
+
run: python -m pytest
|
|
45
|
+
|
|
46
|
+
build:
|
|
47
|
+
needs: test
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/checkout@v7
|
|
51
|
+
with:
|
|
52
|
+
fetch-depth: 0
|
|
53
|
+
|
|
54
|
+
- uses: actions/setup-python@v7
|
|
55
|
+
with:
|
|
56
|
+
python-version: "3.12"
|
|
57
|
+
cache: pip
|
|
58
|
+
|
|
59
|
+
- name: Install system libraries
|
|
60
|
+
run: |
|
|
61
|
+
sudo apt-get update
|
|
62
|
+
sudo apt-get install -y libgirepository-2.0-dev libcairo2-dev pkg-config libglib2.0-bin
|
|
63
|
+
|
|
64
|
+
- name: Build
|
|
65
|
+
run: |
|
|
66
|
+
python -m pip install --upgrade pip build twine
|
|
67
|
+
python -m build . --outdir dist/core # nocturne-shift
|
|
68
|
+
python -m twine check --strict dist/*/*
|
|
69
|
+
|
|
70
|
+
# Install the freshly built wheel the way a user would, so a broken
|
|
71
|
+
# package or dependency fails here, not after the upload.
|
|
72
|
+
#
|
|
73
|
+
# Untagged commits build dev versions (0.4.4.dev3), which pip skips by
|
|
74
|
+
# default. Instead of --pre (pre-releases for *every* package, requests
|
|
75
|
+
# included), pin nocturne-shift to the exact version just built: a
|
|
76
|
+
# pin that names a pre-release allows it for that package only.
|
|
77
|
+
- name: Smoke-test the built wheels
|
|
78
|
+
run: |
|
|
79
|
+
for wheel in dist/*/*.whl; do
|
|
80
|
+
file=$(basename "$wheel") # nocturne_shift-0.4.4.dev3-py3-none-any.whl
|
|
81
|
+
name=${file%%-*} # nocturne_shift
|
|
82
|
+
version=${file#*-}; version=${version%%-*} # 0.4.4.dev3
|
|
83
|
+
echo "${name//_/-}==${version}"
|
|
84
|
+
done > /tmp/constraints.txt
|
|
85
|
+
cat /tmp/constraints.txt
|
|
86
|
+
python -m venv /tmp/smoke
|
|
87
|
+
/tmp/smoke/bin/pip install --constraint /tmp/constraints.txt \
|
|
88
|
+
--find-links dist/core nocturne-shift
|
|
89
|
+
/tmp/smoke/bin/nocturne-shift --version
|
|
90
|
+
|
|
91
|
+
# PyPI is permanent: only upload versions built from a release tag.
|
|
92
|
+
- name: Refuse dev / pre-release builds
|
|
93
|
+
run: |
|
|
94
|
+
if ls dist/core/*.whl | grep -E "\.dev[0-9]+|a[0-9]+-|b[0-9]+-|rc[0-9]+-"; then
|
|
95
|
+
echo "::error::Not a release build. Tag the commit (cli-vX.Y.Z) and run from it."
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
echo "Release versions only:" && ls dist/core/*.whl
|
|
99
|
+
|
|
100
|
+
- uses: actions/upload-artifact@v7
|
|
101
|
+
with:
|
|
102
|
+
name: core-dist
|
|
103
|
+
path: dist/core/
|
|
104
|
+
|
|
105
|
+
publish-core:
|
|
106
|
+
needs: build
|
|
107
|
+
runs-on: ubuntu-latest
|
|
108
|
+
environment: pypi
|
|
109
|
+
permissions:
|
|
110
|
+
id-token: write
|
|
111
|
+
steps:
|
|
112
|
+
- uses: actions/download-artifact@v8
|
|
113
|
+
with:
|
|
114
|
+
name: core-dist
|
|
115
|
+
path: dist/
|
|
116
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
117
|
+
with:
|
|
118
|
+
# PyPI refuses a version twice; skip instead of failing.
|
|
119
|
+
skip-existing: true
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
name: Publish to TestPyPI
|
|
2
|
+
|
|
3
|
+
# Run by hand: Actions tab -> "Publish to TestPyPI" -> Run workflow.
|
|
4
|
+
# Logs in with Trusted Publishing: the nocturne-shift project on test.pypi.org
|
|
5
|
+
# needs a publisher with repository christophermca/nocturne-shift, workflow
|
|
6
|
+
# `publish-testpypi.yml` and environment `test-pypi`.
|
|
7
|
+
# gnome-night-shift is published from its own repo:
|
|
8
|
+
# https://github.com/christophermca/gnome-night-shift
|
|
9
|
+
on:
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v7
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v7
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
cache: pip
|
|
24
|
+
|
|
25
|
+
# PyGObject builds from source on the runner; it needs these headers.
|
|
26
|
+
# libglib2.0-bin provides glib-compile-schemas for the schema tests.
|
|
27
|
+
|
|
28
|
+
- name: Install system libraries
|
|
29
|
+
run: |
|
|
30
|
+
sudo apt-get update
|
|
31
|
+
sudo apt-get install -y libgirepository-2.0-dev libcairo2-dev pkg-config libglib2.0-bin
|
|
32
|
+
|
|
33
|
+
- name: Install dependencies
|
|
34
|
+
run: |
|
|
35
|
+
python -m pip install --upgrade pip
|
|
36
|
+
python -m pip install -e ".[test]"
|
|
37
|
+
|
|
38
|
+
- name: Run tests
|
|
39
|
+
run: python -m pytest
|
|
40
|
+
|
|
41
|
+
build:
|
|
42
|
+
needs: test
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
with:
|
|
47
|
+
fetch-depth: 0
|
|
48
|
+
|
|
49
|
+
- uses: actions/setup-python@v7
|
|
50
|
+
with:
|
|
51
|
+
python-version: "3.12"
|
|
52
|
+
cache: pip
|
|
53
|
+
|
|
54
|
+
- name: Install system libraries
|
|
55
|
+
run: |
|
|
56
|
+
sudo apt-get update
|
|
57
|
+
sudo apt-get install -y libgirepository-2.0-dev libcairo2-dev pkg-config libglib2.0-bin
|
|
58
|
+
|
|
59
|
+
- name: Build
|
|
60
|
+
run: |
|
|
61
|
+
python -m pip install --upgrade pip build twine
|
|
62
|
+
python -m build . --outdir dist/core # nocturne-shift
|
|
63
|
+
python -m twine check --strict dist/*/*
|
|
64
|
+
|
|
65
|
+
# Install the freshly built wheel the way a user would, so a broken
|
|
66
|
+
# package or dependency fails here, not after the upload.
|
|
67
|
+
#
|
|
68
|
+
# Untagged commits build dev versions (0.4.4.dev3), which pip skips by
|
|
69
|
+
# default. Instead of --pre (pre-releases for *every* package, requests
|
|
70
|
+
# included), pin nocturne-shift to the exact version just built: a
|
|
71
|
+
# pin that names a pre-release allows it for that package only.
|
|
72
|
+
- name: Smoke-test the built wheels
|
|
73
|
+
run: |
|
|
74
|
+
for wheel in dist/*/*.whl; do
|
|
75
|
+
file=$(basename "$wheel") # nocturne_shift-0.4.4.dev3-py3-none-any.whl
|
|
76
|
+
name=${file%%-*} # nocturne_shift
|
|
77
|
+
version=${file#*-}; version=${version%%-*} # 0.4.4.dev3
|
|
78
|
+
echo "${name//_/-}==${version}"
|
|
79
|
+
done > /tmp/constraints.txt
|
|
80
|
+
cat /tmp/constraints.txt
|
|
81
|
+
python -m venv /tmp/smoke
|
|
82
|
+
/tmp/smoke/bin/pip install --constraint /tmp/constraints.txt \
|
|
83
|
+
--find-links dist/core nocturne-shift
|
|
84
|
+
/tmp/smoke/bin/nocturne-shift --version
|
|
85
|
+
|
|
86
|
+
- uses: actions/upload-artifact@v7
|
|
87
|
+
with:
|
|
88
|
+
name: core-dist
|
|
89
|
+
path: dist/core/
|
|
90
|
+
|
|
91
|
+
publish-core:
|
|
92
|
+
needs: build
|
|
93
|
+
runs-on: ubuntu-latest
|
|
94
|
+
environment: test-pypi
|
|
95
|
+
permissions:
|
|
96
|
+
id-token: write
|
|
97
|
+
steps:
|
|
98
|
+
- uses: actions/download-artifact@v8
|
|
99
|
+
with:
|
|
100
|
+
name: core-dist
|
|
101
|
+
path: dist/
|
|
102
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
103
|
+
with:
|
|
104
|
+
repository-url: https://test.pypi.org/legacy/
|
|
105
|
+
# TestPyPI refuses a version twice; skip instead of failing.
|
|
106
|
+
skip-existing: true
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.10"]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Check out repository
|
|
17
|
+
uses: actions/checkout@v7
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v7
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
cache: pip
|
|
26
|
+
|
|
27
|
+
# PyGObject builds from source on the runner; it needs these headers.
|
|
28
|
+
# libglib2.0-bin provides glib-compile-schemas for the schema tests.
|
|
29
|
+
- name: Install system libraries
|
|
30
|
+
run: |
|
|
31
|
+
sudo apt-get update
|
|
32
|
+
sudo apt-get install -y libgirepository-2.0-dev libcairo2-dev pkg-config libglib2.0-bin
|
|
33
|
+
|
|
34
|
+
# [test] brings pytest, pytest-cov (pytest.ini uses --cov) and freezegun
|
|
35
|
+
- name: Install dependencies
|
|
36
|
+
run: |
|
|
37
|
+
python -m pip install --upgrade pip
|
|
38
|
+
python -m pip install -e ".[test]"
|
|
39
|
+
|
|
40
|
+
- name: Run tests
|
|
41
|
+
run: python -m pytest
|