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.
Files changed (40) hide show
  1. nocturne_shift-0.5.5/.claude/skills/pytest-review/SKILL.md +140 -0
  2. nocturne_shift-0.5.5/.claude/skills/pytest-review/references/mock-pitfalls.md +108 -0
  3. nocturne_shift-0.5.5/.claude/skills/pytest-review/references/pytest-rosetta.md +36 -0
  4. nocturne_shift-0.5.5/.claude/skills/pytest-review/scripts/mutation_check.py +69 -0
  5. nocturne_shift-0.5.5/.github/workflows/publish-pypi.yml +119 -0
  6. nocturne_shift-0.5.5/.github/workflows/publish-testpypi.yml +106 -0
  7. nocturne_shift-0.5.5/.github/workflows/test.yml +41 -0
  8. nocturne_shift-0.5.5/.gitignore +223 -0
  9. nocturne_shift-0.5.5/LICENSE +674 -0
  10. nocturne_shift-0.5.5/Makefile +70 -0
  11. nocturne_shift-0.5.5/PKG-INFO +113 -0
  12. nocturne_shift-0.5.5/README.md +86 -0
  13. nocturne_shift-0.5.5/docs/README.md +5 -0
  14. nocturne_shift-0.5.5/pyproject.toml +62 -0
  15. nocturne_shift-0.5.5/pytest.ini +5 -0
  16. nocturne_shift-0.5.5/setup.cfg +4 -0
  17. nocturne_shift-0.5.5/src/nocturne_shift/__init__.py +2 -0
  18. nocturne_shift-0.5.5/src/nocturne_shift/bin/get_sunrise_sunset.py +241 -0
  19. nocturne_shift-0.5.5/src/nocturne_shift/bin/is_day_or_night.py +59 -0
  20. nocturne_shift-0.5.5/src/nocturne_shift/bin/settings.py +53 -0
  21. nocturne_shift-0.5.5/src/nocturne_shift/cli.py +217 -0
  22. nocturne_shift-0.5.5/src/nocturne_shift/data_store.py +120 -0
  23. nocturne_shift-0.5.5/src/nocturne_shift/schemas/com.github.christophermca.night-shift.gschema.xml +38 -0
  24. nocturne_shift-0.5.5/src/nocturne_shift/schemas/gschemas.compiled +0 -0
  25. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/PKG-INFO +113 -0
  26. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/SOURCES.txt +38 -0
  27. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/dependency_links.txt +1 -0
  28. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/entry_points.txt +3 -0
  29. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/requires.txt +15 -0
  30. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/scm_file_list.json +34 -0
  31. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/scm_version.json +8 -0
  32. nocturne_shift-0.5.5/src/nocturne_shift.egg-info/top_level.txt +1 -0
  33. nocturne_shift-0.5.5/tests/.gitignore +1 -0
  34. nocturne_shift-0.5.5/tests/conftest.py +61 -0
  35. nocturne_shift-0.5.5/tests/test_cli.py +301 -0
  36. nocturne_shift-0.5.5/tests/test_data_store.py +109 -0
  37. nocturne_shift-0.5.5/tests/test_get_sunrise_sunset.py +251 -0
  38. nocturne_shift-0.5.5/tests/test_is_day_or_night.py +106 -0
  39. nocturne_shift-0.5.5/tests/test_plugins.py +75 -0
  40. 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