hyprmuse 0.3.2__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.
- hyprmuse-0.3.2/.gitignore +18 -0
- hyprmuse-0.3.2/LICENSE +21 -0
- hyprmuse-0.3.2/PKG-INFO +178 -0
- hyprmuse-0.3.2/README.md +156 -0
- hyprmuse-0.3.2/hyprmuse/__init__.py +1 -0
- hyprmuse-0.3.2/hyprmuse/__main__.py +10 -0
- hyprmuse-0.3.2/hyprmuse/cli.py +232 -0
- hyprmuse-0.3.2/hyprmuse/config.py +46 -0
- hyprmuse-0.3.2/hyprmuse/library.py +111 -0
- hyprmuse-0.3.2/hyprmuse/lockscreen.py +224 -0
- hyprmuse-0.3.2/hyprmuse/sources/__init__.py +25 -0
- hyprmuse-0.3.2/hyprmuse/sources/base.py +117 -0
- hyprmuse-0.3.2/hyprmuse/sources/genius.py +201 -0
- hyprmuse-0.3.2/hyprmuse/sources/gutenberg.py +142 -0
- hyprmuse-0.3.2/hyprmuse/sources/poetrydb.py +57 -0
- hyprmuse-0.3.2/hyprmuse/sources/wikiquote.py +138 -0
- hyprmuse-0.3.2/hyprmuse/text.py +49 -0
- hyprmuse-0.3.2/pyproject.toml +93 -0
- hyprmuse-0.3.2/tests/__init__.py +0 -0
- hyprmuse-0.3.2/tests/conftest.py +53 -0
- hyprmuse-0.3.2/tests/test_cli.py +171 -0
- hyprmuse-0.3.2/tests/test_config.py +54 -0
- hyprmuse-0.3.2/tests/test_genius.py +153 -0
- hyprmuse-0.3.2/tests/test_gutenberg.py +83 -0
- hyprmuse-0.3.2/tests/test_library.py +88 -0
- hyprmuse-0.3.2/tests/test_lockscreen.py +276 -0
- hyprmuse-0.3.2/tests/test_poetrydb.py +49 -0
- hyprmuse-0.3.2/tests/test_sources_registry.py +30 -0
- hyprmuse-0.3.2/tests/test_text.py +23 -0
- hyprmuse-0.3.2/tests/test_wikiquote.py +110 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
# leftover scraped output; the library lives in ~/.local/share/hyprmuse/
|
|
5
|
+
lyrics.json
|
|
6
|
+
|
|
7
|
+
# The tracker's specs, tickets and review records (docs/agents/issue-tracker.md)
|
|
8
|
+
.scratch/
|
|
9
|
+
|
|
10
|
+
# What scripts/gate writes
|
|
11
|
+
.coverage
|
|
12
|
+
coverage.xml
|
|
13
|
+
test-results.xml
|
|
14
|
+
quality.md
|
|
15
|
+
|
|
16
|
+
# What packaging/build-binary.sh writes
|
|
17
|
+
build/
|
|
18
|
+
dist/
|
hyprmuse-0.3.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Siegl
|
|
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.
|
hyprmuse-0.3.2/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hyprmuse
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Random quotes from musicians, authors and poets for hyprlock
|
|
5
|
+
Project-URL: Homepage, https://github.com/DavidSiegl/hyprmuse
|
|
6
|
+
Project-URL: Issues, https://github.com/DavidSiegl/hyprmuse/issues
|
|
7
|
+
Project-URL: Releases, https://github.com/DavidSiegl/hyprmuse/releases
|
|
8
|
+
Author: David Siegl
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: hyprland,hyprlock,lockscreen,quotes
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Desktop Environment
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Requires-Dist: beautifulsoup4>=4.14.3
|
|
19
|
+
Requires-Dist: lxml>=6.0.2
|
|
20
|
+
Requires-Dist: requests>=2.32.5
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<h1 align="center">
|
|
24
|
+
<img src="https://raw.githubusercontent.com/DavidSiegl/hyprmuse/main/assets/logo.svg" width="84" height="84" alt="">
|
|
25
|
+
<br>
|
|
26
|
+
hyprmuse
|
|
27
|
+
</h1>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
Random quotes from musicians, literary authors, poets and public-domain books,
|
|
31
|
+
for your <code>hyprlock</code> screen.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
Name a subject, hyprmuse harvests them from a fitting source into a local
|
|
35
|
+
library, and prints a random quote on demand. Everything is stored offline, so
|
|
36
|
+
the lockscreen never waits on the network.
|
|
37
|
+
|
|
38
|
+
## Sources
|
|
39
|
+
|
|
40
|
+
| Source | Domain | Covers | Licence |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| `wikiquote` | quotations | authors, philosophers, scientists, films, TV, proverbs — any language edition | CC BY-SA |
|
|
43
|
+
| `genius` | music | song lyrics for any artist | scraped, personal use |
|
|
44
|
+
| `poetrydb` | poetry | ~130 classic poets, full poems | public domain |
|
|
45
|
+
| `gutenberg` | prose | passages from public-domain books | public domain |
|
|
46
|
+
|
|
47
|
+
Sources fall into two kinds. **Sliceable** items (lyrics, poems) are quoted as a
|
|
48
|
+
random window of consecutive lines taken from within one stanza, so a slice
|
|
49
|
+
never runs from a verse into its chorus. **Atomic** items (a Wikiquote
|
|
50
|
+
quotation, a prose passage) are quoted whole and word-wrapped.
|
|
51
|
+
|
|
52
|
+
Every pick is checked against the box it has to fit (`width`, `max_lines`,
|
|
53
|
+
`max_chars`). A window that overflows is shortened a line at a time, and an
|
|
54
|
+
item that cannot fit at all is skipped in favour of another. Only when nothing
|
|
55
|
+
in the library fits does the output get cut off.
|
|
56
|
+
|
|
57
|
+
Libraries harvested before stanza breaks were stored still work, but slice as
|
|
58
|
+
one long stanza. Run `hyprmuse update --all` once to pick the breaks up.
|
|
59
|
+
|
|
60
|
+
## Setup
|
|
61
|
+
|
|
62
|
+
Download the Linux binary from the [latest release](https://github.com/DavidSiegl/hyprmuse/releases/latest),
|
|
63
|
+
which needs no Python, and check it:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64
|
|
67
|
+
curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64.sha256
|
|
68
|
+
sha256sum -c hyprmuse-linux-x86_64.sha256
|
|
69
|
+
install -D -m 755 hyprmuse-linux-x86_64 ~/.local/bin/hyprmuse
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or run it from a checkout with `uv sync`, then `uv run hyprmuse` in place of `hyprmuse`
|
|
73
|
+
below.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# add subjects — omit --source to search every source and pick from a list
|
|
79
|
+
hyprmuse add "Element of Crime" --source genius
|
|
80
|
+
hyprmuse add "Ingeborg Bachmann" --source wikiquote --lang de
|
|
81
|
+
hyprmuse add "Emily Dickinson" --source poetrydb
|
|
82
|
+
hyprmuse add "Frankenstein" --source gutenberg
|
|
83
|
+
|
|
84
|
+
hyprmuse search "Nietzsche" --lang de # look without harvesting
|
|
85
|
+
hyprmuse list # what's stored
|
|
86
|
+
hyprmuse update --all # re-harvest everything
|
|
87
|
+
hyprmuse remove "genius:342499"
|
|
88
|
+
|
|
89
|
+
hyprmuse quote # a random quote
|
|
90
|
+
hyprmuse quote --domain poetry --width 50
|
|
91
|
+
hyprmuse quote --max-lines 4 --max-chars 160 # fit a small label
|
|
92
|
+
hyprmuse quote --source wikiquote --note
|
|
93
|
+
hyprmuse quote --seed 42 # deterministic
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--lang` selects the Wikiquote language edition (`de`, `en`, `fr`, …). Harvesting
|
|
97
|
+
is throttled and cached; `add` is the slow step, `quote` reads only local files.
|
|
98
|
+
|
|
99
|
+
## Hyprlock integration
|
|
100
|
+
|
|
101
|
+
```ini
|
|
102
|
+
label {
|
|
103
|
+
monitor =
|
|
104
|
+
text = cmd[update:0] ~/.local/bin/hyprmuse quote --width 60
|
|
105
|
+
color = rgba(200, 200, 200, 1.0)
|
|
106
|
+
font_size = 20
|
|
107
|
+
font_family = Noto Sans
|
|
108
|
+
position = 0, 80
|
|
109
|
+
halign = center
|
|
110
|
+
valign = center
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
From a checkout, the command is `/path/to/hyprmuse/.venv/bin/hyprmuse`.
|
|
115
|
+
`quote.py` is kept as a compatibility shim, so an existing config pointing at
|
|
116
|
+
`/path/to/hyprmuse/quote.py` keeps working — only the directory name changed.
|
|
117
|
+
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
Optional, at `~/.config/hyprmuse/config.toml`:
|
|
121
|
+
|
|
122
|
+
```toml
|
|
123
|
+
[quote]
|
|
124
|
+
lines = 3 # lines taken from sliceable items
|
|
125
|
+
width = 60 # wrap width, 0 disables wrapping
|
|
126
|
+
max_lines = 6 # most lines after wrapping, 0 disables
|
|
127
|
+
max_chars = 0 # most characters after wrapping, 0 disables
|
|
128
|
+
min_line_chars = 2 # skip lines shorter than this ("-", "I") when slicing
|
|
129
|
+
weighting = "subject" # "subject" = every subject equally likely
|
|
130
|
+
# "quote" = every quote equally likely
|
|
131
|
+
avoid_repeats = 20 # don't repeat within the last N quotes
|
|
132
|
+
|
|
133
|
+
# per-subject overrides, keyed as shown by `hyprmuse list`
|
|
134
|
+
[subjects."genius:342499"]
|
|
135
|
+
weight = 2.0
|
|
136
|
+
enabled = true
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`weighting = "subject"` is the default so a 162-song band does not drown out an
|
|
140
|
+
author with nine quotes.
|
|
141
|
+
|
|
142
|
+
## Storage
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
~/.local/share/hyprmuse/subjects/*.json one file per subject
|
|
146
|
+
~/.local/share/hyprmuse/recent.json recently shown, for repeat avoidance
|
|
147
|
+
~/.config/hyprmuse/config.toml optional settings
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Harvested text lives outside the repository, so scraped content stays out of git.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
uv run pytest # the tests
|
|
156
|
+
scripts/gate origin/main # the tests with coverage, then octoflow-ai's quality gate on
|
|
157
|
+
# the change since origin/main, as Jenkins runs it; writes quality.md
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Opening a pull request hands it over: once Jenkins passes it, one that changes only the
|
|
161
|
+
package's or the tests' Python, or Markdown, merges into `main` without a human.
|
|
162
|
+
`docs/agents/README.md` has the exact rule and how to hold a pull request back.
|
|
163
|
+
|
|
164
|
+
A merge into `main` that raises `version` in `pyproject.toml` releases it: Jenkins builds
|
|
165
|
+
the binary with `packaging/build-binary.sh` and publishes it as release `v<version>`.
|
|
166
|
+
The Jenkins job `hyprmuse-pypi` (`packaging/pypi/Jenkinsfile`) then builds that release's
|
|
167
|
+
tag on a trusted node and uploads the sdist and wheel to PyPI, and `hyprmuse-aur`
|
|
168
|
+
(`packaging/aur/Jenkinsfile`) pushes the AUR's `hyprmuse-bin` to that release with
|
|
169
|
+
`packaging/aur/publish.sh`. To change the AUR package itself between releases, edit
|
|
170
|
+
`packaging/aur/PKGBUILD`, run `packaging/aur/update.sh` with the latest released version,
|
|
171
|
+
raise `pkgrel`, and run `update.sh` again to refresh `.SRCINFO`. Once merged, the change
|
|
172
|
+
reaches the AUR on the job's next run, after the next build of `main` or within a day.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT; see `LICENSE`. The release binary also bundles Python, the packages hyprmuse
|
|
177
|
+
depends on, and the C libraries linked into them, each under its own license; each
|
|
178
|
+
release carries their notices as `hyprmuse-third-party-licenses.txt`.
|
hyprmuse-0.3.2/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/DavidSiegl/hyprmuse/main/assets/logo.svg" width="84" height="84" alt="">
|
|
3
|
+
<br>
|
|
4
|
+
hyprmuse
|
|
5
|
+
</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Random quotes from musicians, literary authors, poets and public-domain books,
|
|
9
|
+
for your <code>hyprlock</code> screen.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
Name a subject, hyprmuse harvests them from a fitting source into a local
|
|
13
|
+
library, and prints a random quote on demand. Everything is stored offline, so
|
|
14
|
+
the lockscreen never waits on the network.
|
|
15
|
+
|
|
16
|
+
## Sources
|
|
17
|
+
|
|
18
|
+
| Source | Domain | Covers | Licence |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `wikiquote` | quotations | authors, philosophers, scientists, films, TV, proverbs — any language edition | CC BY-SA |
|
|
21
|
+
| `genius` | music | song lyrics for any artist | scraped, personal use |
|
|
22
|
+
| `poetrydb` | poetry | ~130 classic poets, full poems | public domain |
|
|
23
|
+
| `gutenberg` | prose | passages from public-domain books | public domain |
|
|
24
|
+
|
|
25
|
+
Sources fall into two kinds. **Sliceable** items (lyrics, poems) are quoted as a
|
|
26
|
+
random window of consecutive lines taken from within one stanza, so a slice
|
|
27
|
+
never runs from a verse into its chorus. **Atomic** items (a Wikiquote
|
|
28
|
+
quotation, a prose passage) are quoted whole and word-wrapped.
|
|
29
|
+
|
|
30
|
+
Every pick is checked against the box it has to fit (`width`, `max_lines`,
|
|
31
|
+
`max_chars`). A window that overflows is shortened a line at a time, and an
|
|
32
|
+
item that cannot fit at all is skipped in favour of another. Only when nothing
|
|
33
|
+
in the library fits does the output get cut off.
|
|
34
|
+
|
|
35
|
+
Libraries harvested before stanza breaks were stored still work, but slice as
|
|
36
|
+
one long stanza. Run `hyprmuse update --all` once to pick the breaks up.
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
Download the Linux binary from the [latest release](https://github.com/DavidSiegl/hyprmuse/releases/latest),
|
|
41
|
+
which needs no Python, and check it:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64
|
|
45
|
+
curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64.sha256
|
|
46
|
+
sha256sum -c hyprmuse-linux-x86_64.sha256
|
|
47
|
+
install -D -m 755 hyprmuse-linux-x86_64 ~/.local/bin/hyprmuse
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or run it from a checkout with `uv sync`, then `uv run hyprmuse` in place of `hyprmuse`
|
|
51
|
+
below.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# add subjects — omit --source to search every source and pick from a list
|
|
57
|
+
hyprmuse add "Element of Crime" --source genius
|
|
58
|
+
hyprmuse add "Ingeborg Bachmann" --source wikiquote --lang de
|
|
59
|
+
hyprmuse add "Emily Dickinson" --source poetrydb
|
|
60
|
+
hyprmuse add "Frankenstein" --source gutenberg
|
|
61
|
+
|
|
62
|
+
hyprmuse search "Nietzsche" --lang de # look without harvesting
|
|
63
|
+
hyprmuse list # what's stored
|
|
64
|
+
hyprmuse update --all # re-harvest everything
|
|
65
|
+
hyprmuse remove "genius:342499"
|
|
66
|
+
|
|
67
|
+
hyprmuse quote # a random quote
|
|
68
|
+
hyprmuse quote --domain poetry --width 50
|
|
69
|
+
hyprmuse quote --max-lines 4 --max-chars 160 # fit a small label
|
|
70
|
+
hyprmuse quote --source wikiquote --note
|
|
71
|
+
hyprmuse quote --seed 42 # deterministic
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`--lang` selects the Wikiquote language edition (`de`, `en`, `fr`, …). Harvesting
|
|
75
|
+
is throttled and cached; `add` is the slow step, `quote` reads only local files.
|
|
76
|
+
|
|
77
|
+
## Hyprlock integration
|
|
78
|
+
|
|
79
|
+
```ini
|
|
80
|
+
label {
|
|
81
|
+
monitor =
|
|
82
|
+
text = cmd[update:0] ~/.local/bin/hyprmuse quote --width 60
|
|
83
|
+
color = rgba(200, 200, 200, 1.0)
|
|
84
|
+
font_size = 20
|
|
85
|
+
font_family = Noto Sans
|
|
86
|
+
position = 0, 80
|
|
87
|
+
halign = center
|
|
88
|
+
valign = center
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
From a checkout, the command is `/path/to/hyprmuse/.venv/bin/hyprmuse`.
|
|
93
|
+
`quote.py` is kept as a compatibility shim, so an existing config pointing at
|
|
94
|
+
`/path/to/hyprmuse/quote.py` keeps working — only the directory name changed.
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
Optional, at `~/.config/hyprmuse/config.toml`:
|
|
99
|
+
|
|
100
|
+
```toml
|
|
101
|
+
[quote]
|
|
102
|
+
lines = 3 # lines taken from sliceable items
|
|
103
|
+
width = 60 # wrap width, 0 disables wrapping
|
|
104
|
+
max_lines = 6 # most lines after wrapping, 0 disables
|
|
105
|
+
max_chars = 0 # most characters after wrapping, 0 disables
|
|
106
|
+
min_line_chars = 2 # skip lines shorter than this ("-", "I") when slicing
|
|
107
|
+
weighting = "subject" # "subject" = every subject equally likely
|
|
108
|
+
# "quote" = every quote equally likely
|
|
109
|
+
avoid_repeats = 20 # don't repeat within the last N quotes
|
|
110
|
+
|
|
111
|
+
# per-subject overrides, keyed as shown by `hyprmuse list`
|
|
112
|
+
[subjects."genius:342499"]
|
|
113
|
+
weight = 2.0
|
|
114
|
+
enabled = true
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`weighting = "subject"` is the default so a 162-song band does not drown out an
|
|
118
|
+
author with nine quotes.
|
|
119
|
+
|
|
120
|
+
## Storage
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
~/.local/share/hyprmuse/subjects/*.json one file per subject
|
|
124
|
+
~/.local/share/hyprmuse/recent.json recently shown, for repeat avoidance
|
|
125
|
+
~/.config/hyprmuse/config.toml optional settings
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Harvested text lives outside the repository, so scraped content stays out of git.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv run pytest # the tests
|
|
134
|
+
scripts/gate origin/main # the tests with coverage, then octoflow-ai's quality gate on
|
|
135
|
+
# the change since origin/main, as Jenkins runs it; writes quality.md
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Opening a pull request hands it over: once Jenkins passes it, one that changes only the
|
|
139
|
+
package's or the tests' Python, or Markdown, merges into `main` without a human.
|
|
140
|
+
`docs/agents/README.md` has the exact rule and how to hold a pull request back.
|
|
141
|
+
|
|
142
|
+
A merge into `main` that raises `version` in `pyproject.toml` releases it: Jenkins builds
|
|
143
|
+
the binary with `packaging/build-binary.sh` and publishes it as release `v<version>`.
|
|
144
|
+
The Jenkins job `hyprmuse-pypi` (`packaging/pypi/Jenkinsfile`) then builds that release's
|
|
145
|
+
tag on a trusted node and uploads the sdist and wheel to PyPI, and `hyprmuse-aur`
|
|
146
|
+
(`packaging/aur/Jenkinsfile`) pushes the AUR's `hyprmuse-bin` to that release with
|
|
147
|
+
`packaging/aur/publish.sh`. To change the AUR package itself between releases, edit
|
|
148
|
+
`packaging/aur/PKGBUILD`, run `packaging/aur/update.sh` with the latest released version,
|
|
149
|
+
raise `pkgrel`, and run `update.sh` again to refresh `.SRCINFO`. Once merged, the change
|
|
150
|
+
reaches the AUR on the job's next run, after the next build of `main` or within a day.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT; see `LICENSE`. The release binary also bundles Python, the packages hyprmuse
|
|
155
|
+
depends on, and the C libraries linked into them, each under its own license; each
|
|
156
|
+
release carries their notices as `hyprmuse-third-party-licenses.txt`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""hyprmuse - random quotes from musicians, authors and poets for hyprlock."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Run the hyprmuse command: `python -m hyprmuse`, and the release binary's entry point.
|
|
2
|
+
|
|
3
|
+
The import is absolute because packaging/build-binary.sh hands this file to PyInstaller
|
|
4
|
+
as a plain script, outside the package, where a relative import has no parent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hyprmuse.cli import main
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Command line interface for hyprmuse."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import config, library, lockscreen
|
|
7
|
+
from .sources import AUTO_ORDER, REGISTRY, SourceError, get_source
|
|
8
|
+
from .sources.base import Candidate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _err(msg: str) -> int:
|
|
12
|
+
print(f"hyprmuse: {msg}", file=sys.stderr)
|
|
13
|
+
return 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _progress(msg: str) -> None:
|
|
17
|
+
print(f" {msg}", file=sys.stderr)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gather(query: str, source: str, lang: str) -> list[Candidate]:
|
|
21
|
+
"""Candidates from one named source, or from every source in auto order."""
|
|
22
|
+
names = [source] if source else AUTO_ORDER
|
|
23
|
+
found: list[Candidate] = []
|
|
24
|
+
for name in names:
|
|
25
|
+
try:
|
|
26
|
+
hits = get_source(name).search(query, lang)
|
|
27
|
+
except SourceError as exc:
|
|
28
|
+
if source:
|
|
29
|
+
raise
|
|
30
|
+
print(f" ({name} unavailable: {exc})", file=sys.stderr)
|
|
31
|
+
continue
|
|
32
|
+
found.extend(hits)
|
|
33
|
+
if source:
|
|
34
|
+
break
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _choose(cands: list[Candidate], assume_yes: bool) -> Candidate | None:
|
|
39
|
+
if not cands:
|
|
40
|
+
return None
|
|
41
|
+
if assume_yes or len(cands) == 1 or not sys.stdin.isatty():
|
|
42
|
+
return cands[0]
|
|
43
|
+
print("\nMatches:", file=sys.stderr)
|
|
44
|
+
for i, c in enumerate(cands, 1):
|
|
45
|
+
hint = f" ({c.hint})" if c.hint else ""
|
|
46
|
+
print(f" {i:2}. [{c.source}/{c.domain}] {c.name}{hint}", file=sys.stderr)
|
|
47
|
+
try:
|
|
48
|
+
raw = input("\nPick a number (blank to cancel): ").strip()
|
|
49
|
+
except (EOFError, KeyboardInterrupt):
|
|
50
|
+
return None
|
|
51
|
+
if not raw.isdigit() or not 1 <= int(raw) <= len(cands):
|
|
52
|
+
return None
|
|
53
|
+
return cands[int(raw) - 1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_search(args) -> int:
|
|
57
|
+
cands = _gather(args.query, args.source, args.lang)
|
|
58
|
+
if not cands:
|
|
59
|
+
return _err(f"no matches for {args.query!r}")
|
|
60
|
+
for c in cands:
|
|
61
|
+
hint = f" ({c.hint})" if c.hint else ""
|
|
62
|
+
print(f"[{c.source}/{c.domain}] {c.name}{hint}\n {c.source}:{c.source_id}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_add(args) -> int:
|
|
67
|
+
try:
|
|
68
|
+
cands = _gather(args.query, args.source, args.lang)
|
|
69
|
+
except SourceError as exc:
|
|
70
|
+
return _err(str(exc))
|
|
71
|
+
cand = _choose(cands, args.yes)
|
|
72
|
+
if cand is None:
|
|
73
|
+
return _err(f"no match chosen for {args.query!r}")
|
|
74
|
+
print(f"Harvesting {cand.name} from {cand.source}…", file=sys.stderr)
|
|
75
|
+
try:
|
|
76
|
+
subject = get_source(cand.source).fetch(cand.source_id, _progress)
|
|
77
|
+
except SourceError as exc:
|
|
78
|
+
return _err(str(exc))
|
|
79
|
+
path = library.save(subject)
|
|
80
|
+
print(f"Saved {len(subject.items)} items for {subject.name} -> {path}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_list(args) -> int:
|
|
85
|
+
subjects = library.load_all()
|
|
86
|
+
if not subjects:
|
|
87
|
+
print("No subjects yet. Try: hyprmuse add \"Franz Kafka\"")
|
|
88
|
+
return 0
|
|
89
|
+
width = max(len(s.name) for s in subjects)
|
|
90
|
+
total = 0
|
|
91
|
+
for s in subjects:
|
|
92
|
+
total += len(s.items)
|
|
93
|
+
kind = "atomic" if s.items and s.items[0].atomic else "sliceable"
|
|
94
|
+
print(f"{s.name:{width}} {len(s.items):5} items {s.domain:11} "
|
|
95
|
+
f"{s.lang:3} {kind:10} {library.subject_key(s.source, s.source_id)}")
|
|
96
|
+
print(f"\n{len(subjects)} subjects, {total} items total")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_remove(args) -> int:
|
|
101
|
+
matches = library.find(args.token)
|
|
102
|
+
if not matches:
|
|
103
|
+
return _err(f"no subject matching {args.token!r}")
|
|
104
|
+
if len(matches) > 1:
|
|
105
|
+
names = ", ".join(library.subject_key(m.source, m.source_id) for m in matches)
|
|
106
|
+
return _err(f"{args.token!r} is ambiguous: {names}")
|
|
107
|
+
subject = matches[0]
|
|
108
|
+
library.remove(subject)
|
|
109
|
+
print(f"Removed {subject.name}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_update(args) -> int:
|
|
114
|
+
targets = library.load_all() if args.all else library.find(args.token or "")
|
|
115
|
+
if not targets:
|
|
116
|
+
return _err("nothing to update")
|
|
117
|
+
failed = 0
|
|
118
|
+
for subj in targets:
|
|
119
|
+
print(f"Updating {subj.name} ({subj.source})…", file=sys.stderr)
|
|
120
|
+
try:
|
|
121
|
+
fresh = get_source(subj.source).fetch(subj.source_id, _progress)
|
|
122
|
+
except SourceError as exc:
|
|
123
|
+
print(f" failed: {exc}", file=sys.stderr)
|
|
124
|
+
failed += 1
|
|
125
|
+
continue
|
|
126
|
+
library.save(fresh)
|
|
127
|
+
print(f" {len(fresh.items)} items")
|
|
128
|
+
return 1 if failed and failed == len(targets) else 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_sources(args) -> int:
|
|
132
|
+
for name in AUTO_ORDER:
|
|
133
|
+
src = REGISTRY[name]
|
|
134
|
+
kind = "atomic" if src.produces_atomic else "sliceable"
|
|
135
|
+
print(f"{name:11} {src.domain:11} {kind}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_quote(args) -> int:
|
|
140
|
+
cfg = config.load_config()
|
|
141
|
+
for key, flag in (("lines", args.lines), ("width", args.width),
|
|
142
|
+
("max_lines", args.max_lines), ("max_chars", args.max_chars)):
|
|
143
|
+
if flag is not None:
|
|
144
|
+
cfg[key] = flag
|
|
145
|
+
text = lockscreen.quote(cfg, subject=args.subject, domain=args.domain,
|
|
146
|
+
source=args.source, seed=args.seed,
|
|
147
|
+
show_note=args.note)
|
|
148
|
+
if text is None:
|
|
149
|
+
print("No quotes stored yet — run: hyprmuse add \"Franz Kafka\"")
|
|
150
|
+
return 0
|
|
151
|
+
print(text)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
157
|
+
p = argparse.ArgumentParser(
|
|
158
|
+
prog="hyprmuse",
|
|
159
|
+
description="Random quotes from musicians, authors and poets, for hyprlock.")
|
|
160
|
+
sub = p.add_subparsers(dest="cmd")
|
|
161
|
+
|
|
162
|
+
def add_search_flags(sp):
|
|
163
|
+
sp.add_argument("--source", default="", choices=sorted(REGISTRY) + [""],
|
|
164
|
+
help="restrict to one source (default: try all)")
|
|
165
|
+
sp.add_argument("--lang", default="en",
|
|
166
|
+
help="language edition, for wikiquote (e.g. de)")
|
|
167
|
+
|
|
168
|
+
sp = sub.add_parser("add", help="harvest a new subject")
|
|
169
|
+
sp.add_argument("query")
|
|
170
|
+
add_search_flags(sp)
|
|
171
|
+
sp.add_argument("--yes", "-y", action="store_true",
|
|
172
|
+
help="take the best match without prompting")
|
|
173
|
+
sp.set_defaults(func=cmd_add)
|
|
174
|
+
|
|
175
|
+
sp = sub.add_parser("search", help="look up subjects without harvesting")
|
|
176
|
+
sp.add_argument("query")
|
|
177
|
+
add_search_flags(sp)
|
|
178
|
+
sp.set_defaults(func=cmd_search)
|
|
179
|
+
|
|
180
|
+
sp = sub.add_parser("list", help="show stored subjects")
|
|
181
|
+
sp.set_defaults(func=cmd_list)
|
|
182
|
+
|
|
183
|
+
sp = sub.add_parser("remove", help="delete a stored subject")
|
|
184
|
+
sp.add_argument("token", help="source:id or part of the name")
|
|
185
|
+
sp.set_defaults(func=cmd_remove)
|
|
186
|
+
|
|
187
|
+
sp = sub.add_parser("update", help="re-harvest stored subjects")
|
|
188
|
+
sp.add_argument("token", nargs="?", default="")
|
|
189
|
+
sp.add_argument("--all", action="store_true")
|
|
190
|
+
sp.set_defaults(func=cmd_update)
|
|
191
|
+
|
|
192
|
+
sp = sub.add_parser("sources", help="list available sources")
|
|
193
|
+
sp.set_defaults(func=cmd_sources)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
sp = sub.add_parser("quote", help="print a random quote (default command)")
|
|
197
|
+
sp.add_argument("--lines", type=int, default=None,
|
|
198
|
+
help="lines to take from sliceable items")
|
|
199
|
+
sp.add_argument("--width", type=int, default=None, help="wrap width, 0 disables")
|
|
200
|
+
sp.add_argument("--max-lines", type=int, default=None,
|
|
201
|
+
help="most lines after wrapping; shorter picks are preferred")
|
|
202
|
+
sp.add_argument("--max-chars", type=int, default=None,
|
|
203
|
+
help="most characters after wrapping, 0 disables")
|
|
204
|
+
sp.add_argument("--subject", default="", help="restrict to one subject")
|
|
205
|
+
sp.add_argument("--domain", default="", help="restrict to a domain")
|
|
206
|
+
sp.add_argument("--source", default="", help="restrict to a source")
|
|
207
|
+
sp.add_argument("--note", action="store_true", help="include the citation note")
|
|
208
|
+
sp.add_argument("--seed", type=int, default=None, help="deterministic pick")
|
|
209
|
+
sp.set_defaults(func=cmd_quote)
|
|
210
|
+
return p
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> int:
|
|
214
|
+
parser = build_parser()
|
|
215
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
216
|
+
# Bare `hyprmuse` prints a quote, so hyprlock can call it with no arguments.
|
|
217
|
+
if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help")):
|
|
218
|
+
argv.insert(0, "quote")
|
|
219
|
+
args = parser.parse_args(argv)
|
|
220
|
+
if not hasattr(args, "func"):
|
|
221
|
+
parser.print_help()
|
|
222
|
+
return 1
|
|
223
|
+
try:
|
|
224
|
+
return args.func(args)
|
|
225
|
+
except KeyboardInterrupt:
|
|
226
|
+
return 130
|
|
227
|
+
except SourceError as exc:
|
|
228
|
+
return _err(str(exc))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""XDG paths and user configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
APP = "hyprmuse"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _xdg(env: str, fallback: str) -> Path:
|
|
11
|
+
root = os.environ.get(env)
|
|
12
|
+
return (Path(root) if root else Path.home() / fallback) / APP
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DATA_DIR = _xdg("XDG_DATA_HOME", ".local/share")
|
|
16
|
+
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config")
|
|
17
|
+
SUBJECTS_DIR = DATA_DIR / "subjects"
|
|
18
|
+
RECENT_FILE = DATA_DIR / "recent.json"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
20
|
+
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
"lines": 3,
|
|
23
|
+
"width": 60,
|
|
24
|
+
"max_lines": 6,
|
|
25
|
+
"max_chars": 0, # total characters after wrapping, 0 = unlimited
|
|
26
|
+
"weighting": "subject", # "subject" or "quote"
|
|
27
|
+
"avoid_repeats": 20,
|
|
28
|
+
"min_line_chars": 2, # lines shorter than this ("-", "I") are skipped when slicing
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config() -> dict:
|
|
33
|
+
"""Merge config.toml over DEFAULTS. Missing or broken file falls back cleanly."""
|
|
34
|
+
cfg = dict(DEFAULTS)
|
|
35
|
+
cfg["subjects"] = {}
|
|
36
|
+
if not CONFIG_FILE.exists():
|
|
37
|
+
return cfg
|
|
38
|
+
try:
|
|
39
|
+
raw = tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
41
|
+
return cfg
|
|
42
|
+
for key in DEFAULTS:
|
|
43
|
+
if key in raw.get("quote", {}):
|
|
44
|
+
cfg[key] = raw["quote"][key]
|
|
45
|
+
cfg["subjects"] = raw.get("subjects", {})
|
|
46
|
+
return cfg
|