py-bg-changer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mr MRF
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-bg-changer
3
+ Version: 0.1.0
4
+ Summary: A simple cross-platform wallpaper changer made with Python
5
+ Keywords: wallpaper,background,desktop,cli,cross-platform
6
+ Author: Mr MRF Dev
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Environment :: Console
12
+ Requires-Python: >=3.10
13
+ Project-URL: Homepage, https://github.com/Mr-MRF-Dev/Py-Bg-Changer
14
+ Project-URL: Issues, https://github.com/Mr-MRF-Dev/Py-Bg-Changer/issues
15
+ Description-Content-Type: text/markdown
16
+
17
+ # 🖼️ Py Bg Changer
18
+
19
+ Change your desktop wallpaper from the terminal, Win+R or any launcher.
20
+ Works on Windows, macOS and Linux. No dependencies.
21
+
22
+ The command is `py-bg-changer`, and `pbc` is the short alias for it.
23
+
24
+ ## Install
25
+
26
+ With [uv](https://docs.astral.sh/uv/) (recommended):
27
+
28
+ ```bash
29
+ uv tool install py-bg-changer
30
+ ```
31
+
32
+ Or try it without installing:
33
+
34
+ ```bash
35
+ uvx --from py-bg-changer pbc --help
36
+ ```
37
+
38
+ Prefer pip? `pipx install py-bg-changer` or `pip install py-bg-changer` work too. Needs Python 3.9+.
39
+
40
+ ## Use
41
+
42
+ ```bash
43
+ pbc add night ~/Pictures/night.jpg # save an image under a name
44
+ pbc add night img.jpg -a n -a dark # ...and give it extra names (aliases)
45
+ pbc alias night moon # add more aliases later
46
+ pbc rename night midnight # rename a wallpaper or an alias
47
+ pbc night # set it by name (or any alias: pbc n)
48
+ pbc ~/Pictures/any.png # or set any image directly
49
+ pbc list # see what you saved (* marks the default); `pbc ls` works too
50
+ pbc default night # choose what a plain "pbc" sets
51
+ pbc remove night # forget a name (or just an alias)
52
+ pbc # set the default wallpaper
53
+ ```
54
+
55
+ If a saved name clashes with a command (say, one called `list`, `ls` or `default`), use `pbc set list`.
56
+
57
+ ### Win+R and launchers (no console window)
58
+
59
+ Running `pbc` from Win+R opens a terminal that closes right away. Use `pbcw` (or
60
+ `py-bg-changer-w`) instead: it takes the same arguments, opens no window, and shows a
61
+ small message box only if something goes wrong.
62
+
63
+ ```bash
64
+ pbcw night # from Win+R: set it silently
65
+ ```
66
+
67
+ It prints nothing on success, so use `pbc` in a terminal for `list`, `default` and
68
+ the like.
69
+
70
+ ### Aliases
71
+
72
+ A wallpaper can have as many aliases as you like, and every alias works anywhere a name
73
+ does (`pbc n`, `pbc default n`, `pbc alias n moon`). Add them while saving with
74
+ `-a` (repeat it for more) or later with `pbc alias NAME ALIAS...`. `pbc list` shows
75
+ them in brackets after the name.
76
+
77
+ Removing an alias leaves the wallpaper alone; removing a wallpaper also removes its
78
+ aliases. If you replace the image saved under a name, its aliases follow the new image.
79
+
80
+ Rename either one with `pbc rename OLD NEW`. Renaming a wallpaper keeps its aliases, its
81
+ image and its place as the default.
82
+
83
+ ### Default wallpaper
84
+
85
+ Run `pbc` with no arguments and it sets your default wallpaper. Pick the default with
86
+ `pbc default NAME`, or do it while saving an image with `pbc add NAME IMAGE --default`.
87
+ `pbc default` on its own shows the current choice.
88
+
89
+ ### Your images are copied
90
+
91
+ When you save an image, `py-bg-changer` keeps its own copy next to the config file and
92
+ gives that copy to the system. If you later move or delete the original, the wallpaper
93
+ still works. The copy is removed when you remove the name (or replace its image), and
94
+ your original file is never touched.
95
+
96
+ ## Supported desktops
97
+
98
+ | OS | Notes |
99
+ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
100
+ | Windows | Built in, nothing extra needed |
101
+ | macOS | Built in (uses `osascript`) |
102
+ | Linux | GNOME, Budgie, Cinnamon, MATE, KDE Plasma, XFCE and Sway work out of the box. Anything else (i3, bspwm, ...) falls back to `feh` or `nitrogen` if installed. |
103
+
104
+ ## Config
105
+
106
+ Saved names live in a small JSON file, and the copied images in a `wallpapers` folder
107
+ next to it. Run `pbc path` to see where. Set `PBC_CONFIG` to use a different file.
108
+
109
+ ```json
110
+ {
111
+ "wallpapers": {
112
+ "night": "/home/me/.config/py-bg-changer/wallpapers/3e23e8160039594a.png"
113
+ },
114
+ "aliases": { "n": "night", "dark": "night" },
115
+ "default": "night"
116
+ }
117
+ ```
118
+
119
+ ## Development
120
+
121
+ The project is managed with [uv](https://docs.astral.sh/uv/).
122
+
123
+ ```bash
124
+ uv sync # create the virtualenv and install everything
125
+ uv run pytest # run the tests
126
+ uv run pbc --help # run the CLI from source
127
+ ```
128
+
129
+ ### Releasing
130
+
131
+ ```bash
132
+ uv build # builds the sdist and wheel into dist/
133
+ uv publish # uploads them to PyPI
134
+ ```
135
+
136
+ Bump `version` in `pyproject.toml` before each release.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,124 @@
1
+ # 🖼️ Py Bg Changer
2
+
3
+ Change your desktop wallpaper from the terminal, Win+R or any launcher.
4
+ Works on Windows, macOS and Linux. No dependencies.
5
+
6
+ The command is `py-bg-changer`, and `pbc` is the short alias for it.
7
+
8
+ ## Install
9
+
10
+ With [uv](https://docs.astral.sh/uv/) (recommended):
11
+
12
+ ```bash
13
+ uv tool install py-bg-changer
14
+ ```
15
+
16
+ Or try it without installing:
17
+
18
+ ```bash
19
+ uvx --from py-bg-changer pbc --help
20
+ ```
21
+
22
+ Prefer pip? `pipx install py-bg-changer` or `pip install py-bg-changer` work too. Needs Python 3.9+.
23
+
24
+ ## Use
25
+
26
+ ```bash
27
+ pbc add night ~/Pictures/night.jpg # save an image under a name
28
+ pbc add night img.jpg -a n -a dark # ...and give it extra names (aliases)
29
+ pbc alias night moon # add more aliases later
30
+ pbc rename night midnight # rename a wallpaper or an alias
31
+ pbc night # set it by name (or any alias: pbc n)
32
+ pbc ~/Pictures/any.png # or set any image directly
33
+ pbc list # see what you saved (* marks the default); `pbc ls` works too
34
+ pbc default night # choose what a plain "pbc" sets
35
+ pbc remove night # forget a name (or just an alias)
36
+ pbc # set the default wallpaper
37
+ ```
38
+
39
+ If a saved name clashes with a command (say, one called `list`, `ls` or `default`), use `pbc set list`.
40
+
41
+ ### Win+R and launchers (no console window)
42
+
43
+ Running `pbc` from Win+R opens a terminal that closes right away. Use `pbcw` (or
44
+ `py-bg-changer-w`) instead: it takes the same arguments, opens no window, and shows a
45
+ small message box only if something goes wrong.
46
+
47
+ ```bash
48
+ pbcw night # from Win+R: set it silently
49
+ ```
50
+
51
+ It prints nothing on success, so use `pbc` in a terminal for `list`, `default` and
52
+ the like.
53
+
54
+ ### Aliases
55
+
56
+ A wallpaper can have as many aliases as you like, and every alias works anywhere a name
57
+ does (`pbc n`, `pbc default n`, `pbc alias n moon`). Add them while saving with
58
+ `-a` (repeat it for more) or later with `pbc alias NAME ALIAS...`. `pbc list` shows
59
+ them in brackets after the name.
60
+
61
+ Removing an alias leaves the wallpaper alone; removing a wallpaper also removes its
62
+ aliases. If you replace the image saved under a name, its aliases follow the new image.
63
+
64
+ Rename either one with `pbc rename OLD NEW`. Renaming a wallpaper keeps its aliases, its
65
+ image and its place as the default.
66
+
67
+ ### Default wallpaper
68
+
69
+ Run `pbc` with no arguments and it sets your default wallpaper. Pick the default with
70
+ `pbc default NAME`, or do it while saving an image with `pbc add NAME IMAGE --default`.
71
+ `pbc default` on its own shows the current choice.
72
+
73
+ ### Your images are copied
74
+
75
+ When you save an image, `py-bg-changer` keeps its own copy next to the config file and
76
+ gives that copy to the system. If you later move or delete the original, the wallpaper
77
+ still works. The copy is removed when you remove the name (or replace its image), and
78
+ your original file is never touched.
79
+
80
+ ## Supported desktops
81
+
82
+ | OS | Notes |
83
+ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
84
+ | Windows | Built in, nothing extra needed |
85
+ | macOS | Built in (uses `osascript`) |
86
+ | Linux | GNOME, Budgie, Cinnamon, MATE, KDE Plasma, XFCE and Sway work out of the box. Anything else (i3, bspwm, ...) falls back to `feh` or `nitrogen` if installed. |
87
+
88
+ ## Config
89
+
90
+ Saved names live in a small JSON file, and the copied images in a `wallpapers` folder
91
+ next to it. Run `pbc path` to see where. Set `PBC_CONFIG` to use a different file.
92
+
93
+ ```json
94
+ {
95
+ "wallpapers": {
96
+ "night": "/home/me/.config/py-bg-changer/wallpapers/3e23e8160039594a.png"
97
+ },
98
+ "aliases": { "n": "night", "dark": "night" },
99
+ "default": "night"
100
+ }
101
+ ```
102
+
103
+ ## Development
104
+
105
+ The project is managed with [uv](https://docs.astral.sh/uv/).
106
+
107
+ ```bash
108
+ uv sync # create the virtualenv and install everything
109
+ uv run pytest # run the tests
110
+ uv run pbc --help # run the CLI from source
111
+ ```
112
+
113
+ ### Releasing
114
+
115
+ ```bash
116
+ uv build # builds the sdist and wheel into dist/
117
+ uv publish # uploads them to PyPI
118
+ ```
119
+
120
+ Bump `version` in `pyproject.toml` before each release.
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "py-bg-changer"
3
+ version = "0.1.0"
4
+ description = "A simple cross-platform wallpaper changer made with Python"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "wallpaper",
11
+ "background",
12
+ "desktop",
13
+ "cli",
14
+ "cross-platform",
15
+ ]
16
+ dependencies = []
17
+ classifiers = [
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Environment :: Console",
21
+ ]
22
+
23
+ [[project.authors]]
24
+ name = "Mr MRF Dev"
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer"
28
+ Issues = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer/issues"
29
+
30
+ [project.scripts]
31
+ py-bg-changer = "py_bg_changer.cli:main"
32
+ pbc = "py_bg_changer.cli:main"
33
+
34
+ [project.gui-scripts]
35
+ py-bg-changer-w = "py_bg_changer.gui:main"
36
+ pbcw = "py_bg_changer.gui:main"
37
+
38
+ [dependency-groups]
39
+ dev = ["pytest>=9.0.3"]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.13"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "py-bg-changer"
3
+ version = "0.1.0"
4
+ description = "A simple cross-platform wallpaper changer made with Python"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Mr MRF Dev" }]
10
+ keywords = ["wallpaper", "background", "desktop", "cli", "cross-platform"]
11
+ dependencies = []
12
+ classifiers = [
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Environment :: Console",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer"
20
+ Issues = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer/issues"
21
+
22
+ [project.scripts]
23
+ py-bg-changer = "py_bg_changer.cli:main"
24
+ pbc = "py_bg_changer.cli:main"
25
+
26
+ # Same commands without a console window.
27
+ [project.gui-scripts]
28
+ py-bg-changer-w = "py_bg_changer.gui:main"
29
+ pbcw = "py_bg_changer.gui:main"
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=9.0.3"]
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.13"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("py-bg-changer")
5
+ except PackageNotFoundError:
6
+ __version__ = "unknown"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ """Our own copy of every saved wallpaper.
2
+
3
+ When an image is added we copy it next to the config file and hand that copy to
4
+ the operating system, so the wallpaper keeps working even if the original file
5
+ is moved or deleted.
6
+
7
+ Copies are named after a hash of their content: adding the same image under two
8
+ names stores it once, and the copy is deleted when no saved name uses it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+ from . import config
18
+
19
+
20
+ def cache_dir() -> Path:
21
+ return config.config_path().expanduser().resolve().parent / "wallpapers"
22
+
23
+
24
+ def store(source: Path) -> Path:
25
+ """Copy `source` into the cache (if it isn't there yet) and return the copy."""
26
+ digest = hashlib.sha256()
27
+ with source.open("rb") as f:
28
+ for chunk in iter(lambda: f.read(1 << 20), b""):
29
+ digest.update(chunk)
30
+ dest = cache_dir() / f"{digest.hexdigest()[:16]}{source.suffix.lower()}"
31
+ if not dest.exists():
32
+ dest.parent.mkdir(parents=True, exist_ok=True)
33
+ tmp = dest.with_name(dest.name + ".tmp")
34
+ shutil.copyfile(source, tmp)
35
+ tmp.replace(dest)
36
+ return dest
37
+
38
+
39
+ def release(path: str, wallpapers: dict[str, str]) -> None:
40
+ """Delete a cached copy once no saved name points to it any more."""
41
+ if path in wallpapers.values():
42
+ return
43
+ try:
44
+ target = Path(path).expanduser().resolve()
45
+ if target.parent == cache_dir():
46
+ target.unlink()
47
+ except OSError:
48
+ pass
@@ -0,0 +1,400 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import difflib
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__, cache, config
11
+ from .wallpaper import WallpaperError, set_wallpaper
12
+
13
+ COMMANDS = {
14
+ "set",
15
+ "list",
16
+ "ls",
17
+ "add",
18
+ "alias",
19
+ "rename",
20
+ "remove",
21
+ "rm",
22
+ "default",
23
+ "path",
24
+ }
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="py-bg-changer",
30
+ description="Change your desktop wallpaper from the command line.",
31
+ epilog=(
32
+ "'pbc' is a short alias for 'py-bg-changer'.\n\n"
33
+ "examples:\n"
34
+ " pbc add night ~/Pictures/night.jpg save an image under a name\n"
35
+ " pbc add night img.jpg -a n -a dark ...with extra names (aliases)\n"
36
+ " pbc alias night moon add more aliases later\n"
37
+ " pbc rename night midnight rename a wallpaper or an alias\n"
38
+ " pbc night set it by name or alias\n"
39
+ " pbc ~/Pictures/any.png set any image directly\n"
40
+ " pbc default night choose what a plain 'pbc' sets\n"
41
+ " pbc set the default wallpaper"
42
+ ),
43
+ formatter_class=argparse.RawDescriptionHelpFormatter,
44
+ )
45
+ parser.add_argument(
46
+ "--version", action="version", version=f"%(prog)s {__version__}"
47
+ )
48
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
49
+
50
+ p = sub.add_parser("set", help="set the wallpaper (this is the default command)")
51
+ p.add_argument(
52
+ "target",
53
+ nargs="?",
54
+ help="a saved name, an alias or a path to an image (omit it to use the default)",
55
+ )
56
+
57
+ sub.add_parser(
58
+ "list", aliases=["ls"], help="show saved wallpapers (* marks the default)"
59
+ )
60
+
61
+ p = sub.add_parser("add", help="save an image under a name")
62
+ p.add_argument("name")
63
+ p.add_argument("path")
64
+ p.add_argument(
65
+ "-a",
66
+ "--alias",
67
+ action="append",
68
+ dest="aliases",
69
+ metavar="ALIAS",
70
+ help="another name for the same image (repeat it for more)",
71
+ )
72
+ p.add_argument(
73
+ "-d",
74
+ "--default",
75
+ action="store_true",
76
+ dest="make_default",
77
+ help="also make it the default wallpaper",
78
+ )
79
+
80
+ p = sub.add_parser("alias", help="give a saved wallpaper extra names")
81
+ p.add_argument("name", help="a saved name (or one of its aliases)")
82
+ p.add_argument("aliases", nargs="+", metavar="ALIAS")
83
+
84
+ p = sub.add_parser("rename", help="rename a saved wallpaper or an alias")
85
+ p.add_argument("old", help="a saved name or an alias")
86
+ p.add_argument("new")
87
+
88
+ p = sub.add_parser("default", help="choose the wallpaper that a plain 'pbc' sets")
89
+ p.add_argument(
90
+ "name", nargs="?", help="a saved name or alias (omit it to show the default)"
91
+ )
92
+
93
+ p = sub.add_parser("remove", aliases=["rm"], help="forget a saved name or alias")
94
+ p.add_argument("name")
95
+
96
+ sub.add_parser("path", help="print where the config file lives")
97
+ return parser
98
+
99
+
100
+ def _error(message: str) -> int:
101
+ print(message, file=sys.stderr)
102
+ return 1
103
+
104
+
105
+ def _dedupe(items: list[str]) -> list[str]:
106
+ return list(dict.fromkeys(items))
107
+
108
+
109
+ def _resolve(
110
+ name: str, wallpapers: dict[str, str], aliases: dict[str, str]
111
+ ) -> str | None:
112
+ """The saved wallpaper a name or alias stands for, or None."""
113
+ if name in wallpapers:
114
+ return name
115
+ target = aliases.get(name)
116
+ return target if target in wallpapers else None
117
+
118
+
119
+ def _aliases_of(name: str, aliases: dict[str, str]) -> list[str]:
120
+ return [alias for alias, target in aliases.items() if target == name]
121
+
122
+
123
+ def _label(name: str, aliases: dict[str, str]) -> str:
124
+ extra = _aliases_of(name, aliases)
125
+ return f"{name} ({', '.join(extra)})" if extra else name
126
+
127
+
128
+ def _check_aliases(
129
+ owner: str,
130
+ requested: list[str],
131
+ wallpapers: dict[str, str],
132
+ aliases: dict[str, str],
133
+ ) -> str | None:
134
+ """An error message if one of the aliases can't be used for `owner`."""
135
+ for alias in requested:
136
+ if alias == owner:
137
+ return f"'{alias}' is already the name of this wallpaper."
138
+ if alias in wallpapers:
139
+ return f"'{alias}' is already a saved wallpaper name."
140
+ if alias in aliases and aliases[alias] != owner:
141
+ return f"'{alias}' is already an alias of '{aliases[alias]}'."
142
+ return None
143
+
144
+
145
+ def _unknown(target: str, wallpapers: dict[str, str], aliases: dict[str, str]) -> int:
146
+ message = f"Unknown wallpaper: '{target}'"
147
+ close = difflib.get_close_matches(target, [*wallpapers, *aliases], n=1)
148
+ if close:
149
+ message += f"\nDid you mean '{close[0]}'?"
150
+ if wallpapers:
151
+ labels = ", ".join(_label(name, aliases) for name in wallpapers)
152
+ message += f"\nAvailable: {labels}"
153
+ else:
154
+ message += "\nNothing is saved yet. Save one with: pbc add NAME IMAGE"
155
+ return _error(message)
156
+
157
+
158
+ def _default_name(wallpapers: dict[str, str], aliases: dict[str, str]) -> str | None:
159
+ chosen = config.get_default()
160
+ if chosen:
161
+ resolved = _resolve(chosen, wallpapers, aliases)
162
+ if resolved:
163
+ return resolved
164
+ # Configs from the original bg.py use a wallpaper literally named "default".
165
+ if "default" in wallpapers:
166
+ return "default"
167
+ return None
168
+
169
+
170
+ def cmd_set(target: str | None) -> int:
171
+ wallpapers = config.load()
172
+ aliases = config.load_aliases()
173
+
174
+ if target is None:
175
+ target = _default_name(wallpapers, aliases)
176
+ if target is None:
177
+ if not wallpapers:
178
+ return _error(
179
+ "Nothing is saved yet. Save one with: pbc add NAME IMAGE --default"
180
+ )
181
+ labels = ", ".join(_label(name, aliases) for name in wallpapers)
182
+ return _error(
183
+ "No default wallpaper is set. Choose one with: pbc default NAME\n"
184
+ f"Available: {labels}"
185
+ )
186
+
187
+ saved = _resolve(target, wallpapers, aliases)
188
+ if saved is not None:
189
+ image = Path(wallpapers[saved]).expanduser()
190
+ label = f"'{saved}'"
191
+ elif Path(target).expanduser().is_file():
192
+ image = Path(target).expanduser()
193
+ label = str(image)
194
+ else:
195
+ return _unknown(target, wallpapers, aliases)
196
+
197
+ if not image.is_file():
198
+ message = f"File not found: {image}"
199
+ if saved is not None:
200
+ message += f"\nAdd it again with: pbc add {saved} IMAGE"
201
+ return _error(message)
202
+
203
+ set_wallpaper(image)
204
+ print(f"Wallpaper set to {label}")
205
+ return 0
206
+
207
+
208
+ def cmd_list() -> int:
209
+ wallpapers = config.load()
210
+ aliases = config.load_aliases()
211
+ if not wallpapers:
212
+ print("Nothing saved yet. Save one with: pbc add NAME IMAGE")
213
+ return 0
214
+ default = _default_name(wallpapers, aliases)
215
+ labels = {name: _label(name, aliases) for name in wallpapers}
216
+ width = max(len(label) for label in labels.values())
217
+ for name, path in wallpapers.items():
218
+ mark = "*" if name == default else " "
219
+ note = "" if Path(path).expanduser().is_file() else " (missing)"
220
+ print(f"{mark} {labels[name]:<{width}} {path}{note}")
221
+ return 0
222
+
223
+
224
+ def cmd_add(
225
+ name: str, path: str, make_default: bool, alias_names: list[str] | None
226
+ ) -> int:
227
+ source = Path(path).expanduser().resolve()
228
+ if not source.is_file():
229
+ return _error(f"File not found: {source}")
230
+
231
+ wallpapers = config.load()
232
+ aliases = config.load_aliases()
233
+ if name in aliases:
234
+ return _error(
235
+ f"'{name}' is already an alias of '{aliases[name]}'.\n"
236
+ f"Remove the alias first with: pbc remove {name}"
237
+ )
238
+ requested = _dedupe(alias_names or [])
239
+ problem = _check_aliases(name, requested, wallpapers, aliases)
240
+ if problem:
241
+ return _error(problem)
242
+
243
+ try:
244
+ stored = cache.store(source)
245
+ except OSError as exc:
246
+ return _error(f"Could not copy {source}: {exc}")
247
+
248
+ previous = wallpapers.get(name)
249
+ wallpapers[name] = str(stored)
250
+ for alias in requested:
251
+ aliases[alias] = name
252
+ config.save(wallpapers, aliases)
253
+ if previous is not None:
254
+ cache.release(previous, wallpapers)
255
+
256
+ print(f"Saved '{name}' -> {stored}")
257
+ if requested:
258
+ print(f"Aliases: {', '.join(requested)}")
259
+ if make_default:
260
+ config.set_default(name)
261
+ print(f"'{name}' is now the default wallpaper")
262
+ return 0
263
+
264
+
265
+ def cmd_alias(name: str, new_aliases: list[str]) -> int:
266
+ wallpapers = config.load()
267
+ aliases = config.load_aliases()
268
+ real = _resolve(name, wallpapers, aliases)
269
+ if real is None:
270
+ return _unknown(name, wallpapers, aliases)
271
+
272
+ requested = _dedupe(new_aliases)
273
+ problem = _check_aliases(real, requested, wallpapers, aliases)
274
+ if problem:
275
+ return _error(problem)
276
+
277
+ added = [alias for alias in requested if alias not in aliases]
278
+ if not added:
279
+ print(f"'{real}' already has those aliases")
280
+ return 0
281
+ for alias in added:
282
+ aliases[alias] = real
283
+ config.save(wallpapers, aliases)
284
+ print(f"'{real}' is now also called: {', '.join(added)}")
285
+ return 0
286
+
287
+
288
+ def cmd_rename(old: str, new: str) -> int:
289
+ wallpapers = config.load()
290
+ aliases = config.load_aliases()
291
+ if old not in wallpapers and old not in aliases:
292
+ return _unknown(old, wallpapers, aliases)
293
+ if new == old:
294
+ print(f"'{old}' is already called that")
295
+ return 0
296
+ if new in wallpapers:
297
+ return _error(f"'{new}' is already a saved wallpaper name.")
298
+ if new in aliases:
299
+ return _error(f"'{new}' is already an alias of '{aliases[new]}'.")
300
+
301
+ # Rebuild the dicts so the renamed entry keeps its place in the list.
302
+ if old in wallpapers:
303
+ wallpapers = {
304
+ (new if name == old else name): path for name, path in wallpapers.items()
305
+ }
306
+ aliases = {
307
+ alias: (new if target == old else target)
308
+ for alias, target in aliases.items()
309
+ }
310
+ what = f"'{old}'"
311
+ else:
312
+ aliases = {
313
+ (new if alias == old else alias): target
314
+ for alias, target in aliases.items()
315
+ }
316
+ what = f"alias '{old}'"
317
+ config.save(wallpapers, aliases)
318
+ if config.get_default() == old:
319
+ config.set_default(new)
320
+ print(f"Renamed {what} to '{new}'")
321
+ return 0
322
+
323
+
324
+ def cmd_default(name: str | None) -> int:
325
+ wallpapers = config.load()
326
+ aliases = config.load_aliases()
327
+ if name is None:
328
+ current = config.get_default()
329
+ resolved = _resolve(current, wallpapers, aliases) if current else None
330
+ if resolved:
331
+ print(resolved)
332
+ else:
333
+ print("No default set. Choose one with: pbc default NAME")
334
+ return 0
335
+ resolved = _resolve(name, wallpapers, aliases)
336
+ if resolved is None:
337
+ return _unknown(name, wallpapers, aliases)
338
+ config.set_default(resolved)
339
+ print(f"'{resolved}' is now the default wallpaper")
340
+ return 0
341
+
342
+
343
+ def cmd_remove(name: str) -> int:
344
+ wallpapers = config.load()
345
+ aliases = config.load_aliases()
346
+
347
+ if name in wallpapers:
348
+ removed = wallpapers.pop(name)
349
+ gone = _aliases_of(name, aliases)
350
+ for alias in gone:
351
+ del aliases[alias]
352
+ config.save(wallpapers, aliases)
353
+ if config.get_default() == name:
354
+ config.set_default(None)
355
+ cache.release(removed, wallpapers)
356
+ message = f"Removed '{name}'"
357
+ if gone:
358
+ message += f" (and its aliases: {', '.join(gone)})"
359
+ print(message)
360
+ return 0
361
+
362
+ if name in aliases:
363
+ target = aliases.pop(name)
364
+ config.save(wallpapers, aliases)
365
+ print(f"Removed alias '{name}' (the wallpaper '{target}' is still saved)")
366
+ return 0
367
+
368
+ return _error(f"Unknown wallpaper: '{name}'")
369
+
370
+
371
+ def main(argv: list[str] | None = None) -> int:
372
+ argv = list(sys.argv[1:] if argv is None else argv)
373
+
374
+ # "pbc night" is shorthand for "pbc set night".
375
+ if not argv or (argv[0] not in COMMANDS and not argv[0].startswith("-")):
376
+ argv.insert(0, "set")
377
+
378
+ args = build_parser().parse_args(argv)
379
+
380
+ try:
381
+ if args.command == "set":
382
+ return cmd_set(args.target)
383
+ if args.command in ("list", "ls"):
384
+ return cmd_list()
385
+ if args.command == "add":
386
+ return cmd_add(args.name, args.path, args.make_default, args.aliases)
387
+ if args.command == "alias":
388
+ return cmd_alias(args.name, args.aliases)
389
+ if args.command == "rename":
390
+ return cmd_rename(args.old, args.new)
391
+ if args.command == "default":
392
+ return cmd_default(args.name)
393
+ if args.command in ("remove", "rm"):
394
+ return cmd_remove(args.name)
395
+ if args.command == "path":
396
+ print(config.config_path())
397
+ return 0
398
+ except (config.ConfigError, WallpaperError) as exc:
399
+ return _error(str(exc))
400
+ return 0
@@ -0,0 +1,117 @@
1
+ """Reading and writing the saved wallpapers and the chosen default.
2
+
3
+ The file format is the same as the original bg.py, plus an optional default and
4
+ optional aliases (extra names for a saved wallpaper):
5
+
6
+ {
7
+ "wallpapers": {"night": "/path/to/image.jpg"},
8
+ "aliases": {"n": "night", "dark": "night"},
9
+ "default": "night"
10
+ }
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import platform
18
+ from pathlib import Path
19
+
20
+ APP_NAME = "py-bg-changer"
21
+
22
+
23
+ class ConfigError(Exception):
24
+ """The config file exists but cannot be used."""
25
+
26
+
27
+ def config_dir() -> Path:
28
+ system = platform.system()
29
+ if system == "Windows":
30
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
31
+ elif system == "Darwin":
32
+ base = Path.home() / "Library" / "Application Support"
33
+ else:
34
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
35
+ return base / APP_NAME
36
+
37
+
38
+ def config_path() -> Path:
39
+ """Default location, or whatever PBC_CONFIG points to."""
40
+ override = os.environ.get("PBC_CONFIG")
41
+ if override:
42
+ return Path(override).expanduser()
43
+ return config_dir() / "config.json"
44
+
45
+
46
+ def _read_raw() -> dict:
47
+ path = config_path()
48
+ if not path.exists():
49
+ return {}
50
+ try:
51
+ data = json.loads(path.read_text(encoding="utf-8"))
52
+ except (OSError, json.JSONDecodeError) as exc:
53
+ raise ConfigError(f"Could not read {path}: {exc}") from exc
54
+ if not isinstance(data, dict):
55
+ raise ConfigError(f"{path} has an unexpected format (expected a JSON object).")
56
+ return data
57
+
58
+
59
+ def _write(data: dict) -> None:
60
+ path = config_path()
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ tmp = path.with_name(path.name + ".tmp")
63
+ tmp.write_text(
64
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
65
+ )
66
+ tmp.replace(path)
67
+
68
+
69
+ def load() -> dict[str, str]:
70
+ """Return the saved wallpapers as {name: path}. Empty if nothing is saved yet."""
71
+ wallpapers = _read_raw().get("wallpapers", {})
72
+ if not isinstance(wallpapers, dict):
73
+ raise ConfigError(
74
+ f"{config_path()}: 'wallpapers' must be an object of name -> path."
75
+ )
76
+ return {str(name): str(path) for name, path in wallpapers.items()}
77
+
78
+
79
+ def load_aliases() -> dict[str, str]:
80
+ """Return the aliases as {alias: wallpaper name}. Empty if there are none."""
81
+ aliases = _read_raw().get("aliases", {})
82
+ if not isinstance(aliases, dict):
83
+ raise ConfigError(
84
+ f"{config_path()}: 'aliases' must be an object of alias -> name."
85
+ )
86
+ return {str(alias): str(name) for alias, name in aliases.items()}
87
+
88
+
89
+ def save(wallpapers: dict[str, str], aliases: dict[str, str] | None = None) -> None:
90
+ """Write the wallpapers back, keeping any other keys already in the file.
91
+
92
+ The aliases are only touched when they are passed in.
93
+ """
94
+ data = _read_raw()
95
+ data["wallpapers"] = wallpapers
96
+ if aliases is not None:
97
+ if aliases:
98
+ data["aliases"] = aliases
99
+ else:
100
+ data.pop("aliases", None)
101
+ _write(data)
102
+
103
+
104
+ def get_default() -> str | None:
105
+ """Name of the wallpaper that a plain `pbc` sets, if one was chosen."""
106
+ value = _read_raw().get("default")
107
+ return value if isinstance(value, str) and value else None
108
+
109
+
110
+ def set_default(name: str | None) -> None:
111
+ """Choose the default wallpaper, or pass None to clear the choice."""
112
+ data = _read_raw()
113
+ if name is None:
114
+ data.pop("default", None)
115
+ else:
116
+ data["default"] = name
117
+ _write(data)
@@ -0,0 +1,46 @@
1
+ """Entry point without a console window, for Win+R and other launchers.
2
+
3
+ It runs the same commands as `pbc`, but stays silent when everything works and
4
+ shows a small message box when something goes wrong (there is no console to
5
+ print errors to).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import sys
13
+
14
+ from .cli import main as cli_main
15
+
16
+ TITLE = "py-bg-changer"
17
+
18
+
19
+ def _show_error(message: str) -> None:
20
+ if sys.platform == "win32":
21
+ import ctypes
22
+
23
+ MB_ICONERROR = 0x10
24
+ MB_TOPMOST = 0x40000
25
+ ctypes.windll.user32.MessageBoxW(
26
+ None, message, TITLE, MB_ICONERROR | MB_TOPMOST
27
+ )
28
+ else:
29
+ print(message, file=sys.stderr)
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ out, err = io.StringIO(), io.StringIO()
34
+ code = 0
35
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
36
+ try:
37
+ code = cli_main(argv)
38
+ except SystemExit as exc: # argparse exits on bad arguments, --help, --version
39
+ code = exc.code if isinstance(exc.code, int) else int(exc.code is not None)
40
+ except Exception as exc: # never let a traceback vanish into a missing console
41
+ code = 1
42
+ err.write(f"Unexpected error: {exc}")
43
+ if code != 0:
44
+ message = err.getvalue().strip() or out.getvalue().strip()
45
+ _show_error(message or f"Something went wrong (exit code {code}).")
46
+ return code
@@ -0,0 +1,128 @@
1
+ """Set the desktop wallpaper on Windows, macOS and Linux."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+
12
+ class WallpaperError(Exception):
13
+ """The wallpaper could not be applied."""
14
+
15
+
16
+ def set_wallpaper(path: Path) -> None:
17
+ path = Path(path).expanduser().resolve()
18
+ system = platform.system()
19
+ if system == "Windows":
20
+ _windows(path)
21
+ elif system == "Darwin":
22
+ _macos(path)
23
+ elif system == "Linux":
24
+ _linux(path)
25
+ else:
26
+ raise WallpaperError(f"Unsupported operating system: {system}")
27
+
28
+
29
+ def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
30
+ if shutil.which(cmd[0]) is None:
31
+ raise WallpaperError(f"'{cmd[0]}' was not found on this system.")
32
+ result = subprocess.run(cmd, capture_output=True, text=True)
33
+ if check and result.returncode != 0:
34
+ detail = result.stderr.strip() or f"exit code {result.returncode}"
35
+ raise WallpaperError(f"{cmd[0]} failed: {detail}")
36
+ return result
37
+
38
+
39
+ # --- Windows ---------------------------------------------------------------
40
+
41
+
42
+ def _windows(path: Path) -> None:
43
+ import ctypes
44
+
45
+ SPI_SETDESKWALLPAPER = 0x0014
46
+ SPIF_UPDATEINIFILE = 0x01
47
+ SPIF_SENDCHANGE = 0x02
48
+ ok = ctypes.windll.user32.SystemParametersInfoW(
49
+ SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
50
+ )
51
+ if not ok:
52
+ raise WallpaperError("Windows refused to change the wallpaper.")
53
+
54
+
55
+ # --- macOS -----------------------------------------------------------------
56
+
57
+
58
+ def _macos(path: Path) -> None:
59
+ escaped = str(path).replace("\\", "\\\\").replace('"', '\\"')
60
+ script = (
61
+ 'tell application "System Events" to tell every desktop '
62
+ f'to set picture to "{escaped}"'
63
+ )
64
+ _run(["osascript", "-e", script])
65
+
66
+
67
+ # --- Linux -----------------------------------------------------------------
68
+
69
+
70
+ def _linux(path: Path) -> None:
71
+ desktop = ":".join(
72
+ filter(
73
+ None,
74
+ [os.environ.get("XDG_CURRENT_DESKTOP"), os.environ.get("DESKTOP_SESSION")],
75
+ )
76
+ ).lower()
77
+ uri = path.as_uri()
78
+
79
+ if "cinnamon" in desktop:
80
+ _run(
81
+ ["gsettings", "set", "org.cinnamon.desktop.background", "picture-uri", uri]
82
+ )
83
+ elif "mate" in desktop:
84
+ _run(["gsettings", "set", "org.mate.background", "picture-filename", str(path)])
85
+ elif any(name in desktop for name in ("gnome", "budgie", "unity", "pantheon")):
86
+ _run(["gsettings", "set", "org.gnome.desktop.background", "picture-uri", uri])
87
+ # Newer GNOME has a separate key for dark mode; older versions don't.
88
+ _run(
89
+ [
90
+ "gsettings",
91
+ "set",
92
+ "org.gnome.desktop.background",
93
+ "picture-uri-dark",
94
+ uri,
95
+ ],
96
+ check=False,
97
+ )
98
+ elif "kde" in desktop or "plasma" in desktop:
99
+ _run(["plasma-apply-wallpaperimage", str(path)])
100
+ elif "xfce" in desktop:
101
+ _xfce(path)
102
+ elif "sway" in desktop:
103
+ _run(["swaymsg", "output", "*", "bg", str(path), "fill"])
104
+ else:
105
+ _linux_fallback(path)
106
+
107
+
108
+ def _xfce(path: Path) -> None:
109
+ props = _run(["xfconf-query", "-c", "xfce4-desktop", "-l"]).stdout.splitlines()
110
+ targets = [p for p in props if p.endswith("/last-image")]
111
+ if not targets:
112
+ raise WallpaperError("Could not find the XFCE wallpaper settings.")
113
+ for prop in targets:
114
+ _run(["xfconf-query", "-c", "xfce4-desktop", "-p", prop, "-s", str(path)])
115
+
116
+
117
+ def _linux_fallback(path: Path) -> None:
118
+ for cmd in (
119
+ ["feh", "--bg-fill", str(path)],
120
+ ["nitrogen", "--set-zoom-fill", "--save", str(path)],
121
+ ):
122
+ if shutil.which(cmd[0]):
123
+ _run(cmd)
124
+ return
125
+ raise WallpaperError(
126
+ "Could not detect your desktop environment. "
127
+ "Install 'feh' (or 'nitrogen') and try again."
128
+ )