Skip to content
This repository was archived by the owner on Mar 22, 2025. It is now read-only.

Commit c43e6ee

Browse files
committed
feat: Integrate click for cli and config-parsing
1 parent ccf67d9 commit c43e6ee

File tree

10 files changed

+542
-357
lines changed

10 files changed

+542
-357
lines changed

podcast_archiver/__main__.py

Lines changed: 0 additions & 40 deletions
This file was deleted.

podcast_archiver/argparse.py

Lines changed: 0 additions & 84 deletions
This file was deleted.

podcast_archiver/base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from podcast_archiver import constants
1919

2020
if TYPE_CHECKING:
21-
from podcast_archiver.config import Settings
21+
from podcast_archiver.settings import Settings
2222

2323

2424
class PodcastArchiver:
@@ -196,7 +196,7 @@ def truncateLinkList(self, linklist, feed_info):
196196
link = episode_dict["url"]
197197
filename = self.linkToTargetFilename(link, feed_info)
198198

199-
if path.isfile(filename):
199+
if filename and path.isfile(filename):
200200
del linklist[index:]
201201
if self.verbose > 1:
202202
print(f" found existing episodes, {len(linklist)} new to process")
@@ -221,7 +221,7 @@ def parseFeedInfo(self, feedobj):
221221
return None
222222

223223
def processPodcastLink(self, feed_next_page):
224-
feed_info = None
224+
feed_info = {}
225225
linklist = []
226226
while True:
227227
if not (feedobj := self.getFeedObj(feed_next_page)):
@@ -255,7 +255,7 @@ def checkEpisodeExistsPreflight(self, link, *, feed_info, episode_dict):
255255
if self.verbose > 1:
256256
print("\tLocal filename:", filename)
257257

258-
if path.isfile(filename):
258+
if filename and path.isfile(filename):
259259
if self.verbose > 1:
260260
print("\t✓ Already exists.")
261261
return None

podcast_archiver/cli.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import pathlib
2+
from typing import Any
3+
4+
import rich_click as click
5+
from pydantic import ValidationError
6+
7+
from podcast_archiver import __version__ as version
8+
from podcast_archiver.base import PodcastArchiver
9+
from podcast_archiver.config import DEFAULT_SETTINGS, Settings
10+
from podcast_archiver.constants import ENVVAR_PREFIX, PROG_NAME
11+
12+
click.rich_click.USE_RICH_MARKUP = True
13+
click.rich_click.USE_MARKDOWN = True
14+
click.rich_click.OPTIONS_PANEL_TITLE = "Miscellaneous Options"
15+
click.rich_click.OPTION_GROUPS = {
16+
PROG_NAME: [
17+
{
18+
"name": "Basic parameters",
19+
"options": [
20+
"--feed",
21+
"--opml",
22+
"--dir",
23+
"--config",
24+
],
25+
},
26+
{
27+
"name": "Processing parameters",
28+
"options": [
29+
"--subdirs",
30+
"--update",
31+
"--slugify",
32+
"--max-episodes",
33+
"--date-prefix",
34+
],
35+
},
36+
]
37+
}
38+
39+
40+
@click.command(
41+
context_settings={
42+
"auto_envvar_prefix": ENVVAR_PREFIX,
43+
},
44+
help="Archive all of your favorite podcasts",
45+
)
46+
@click.help_option("-h", "--help")
47+
@click.option(
48+
"-f",
49+
"--feed",
50+
multiple=True,
51+
show_envvar=True,
52+
help="Feed URLs to archive. Use repeatedly for multiple feeds.",
53+
)
54+
@click.option(
55+
"-o",
56+
"--opml",
57+
multiple=True,
58+
show_envvar=True,
59+
help=(
60+
"OPML files (as exported by many other podcatchers) containing feed URLs to archive. "
61+
"Use repeatedly for multiple files."
62+
),
63+
)
64+
@click.option(
65+
"-d",
66+
"--dir",
67+
type=click.Path(
68+
exists=False,
69+
writable=True,
70+
file_okay=False,
71+
dir_okay=True,
72+
resolve_path=True,
73+
path_type=pathlib.Path,
74+
),
75+
show_default=True,
76+
required=False,
77+
default=DEFAULT_SETTINGS.archive_directory,
78+
show_envvar=True,
79+
help="Directory to which to download the podcast archive",
80+
)
81+
@click.option(
82+
"-s",
83+
"--subdirs",
84+
type=bool,
85+
default=DEFAULT_SETTINGS.create_subdirectories,
86+
is_flag=True,
87+
show_envvar=True,
88+
help="Place downloaded podcasts in separate subdirectories per podcast (named with their title).",
89+
)
90+
@click.option(
91+
"-u",
92+
"--update",
93+
type=bool,
94+
default=DEFAULT_SETTINGS.update_archive,
95+
is_flag=True,
96+
show_envvar=True,
97+
help=(
98+
"Update the feeds with newly added episodes only. "
99+
"Adding episodes ends with the first episode already present in the download directory."
100+
),
101+
)
102+
@click.option(
103+
"-p",
104+
"--progress",
105+
type=bool,
106+
default=DEFAULT_SETTINGS.show_progress_bars,
107+
is_flag=True,
108+
show_envvar=True,
109+
help="Show progress bars while downloading episodes.",
110+
)
111+
@click.option(
112+
"-v",
113+
"--verbose",
114+
count=True,
115+
default=DEFAULT_SETTINGS.verbose,
116+
help="Increase the level of verbosity while downloading.",
117+
)
118+
@click.option(
119+
"-S",
120+
"--slugify",
121+
type=bool,
122+
default=DEFAULT_SETTINGS.slugify_paths,
123+
is_flag=True,
124+
show_envvar=True,
125+
help="Format filenames in the most compatible way, replacing all special characters.",
126+
)
127+
@click.option(
128+
"-m",
129+
"--max-episodes",
130+
type=int,
131+
default=DEFAULT_SETTINGS.maximum_episode_count,
132+
help=(
133+
"Only download the given number of episodes per podcast feed. "
134+
"Useful if you don't really need the entire backlog."
135+
),
136+
)
137+
@click.option(
138+
"--date-prefix",
139+
type=bool,
140+
default=DEFAULT_SETTINGS.add_date_prefix,
141+
is_flag=True,
142+
show_envvar=True,
143+
help="Prefix episodes with their publishing date. Useful to ensure chronological ordering.",
144+
)
145+
@click.version_option(
146+
version,
147+
"-V",
148+
"--version",
149+
prog_name=PROG_NAME,
150+
)
151+
@click.option(
152+
"--config-generate",
153+
type=bool,
154+
expose_value=False,
155+
is_flag=True,
156+
callback=Settings.click_callback_generate,
157+
is_eager=True,
158+
help="Emit an example YAML config file to stdout and exit.",
159+
)
160+
@click.option(
161+
"-c",
162+
"--config",
163+
type=click.Path(
164+
readable=True,
165+
file_okay=True,
166+
dir_okay=False,
167+
resolve_path=True,
168+
path_type=pathlib.Path,
169+
),
170+
expose_value=False,
171+
default=pathlib.Path(click.get_app_dir(PROG_NAME)) / "config.yaml",
172+
show_default=True,
173+
callback=Settings.click_callback_load,
174+
is_eager=True,
175+
show_envvar=True,
176+
help="Path to a config file. Command line arguments will take precedence.",
177+
)
178+
@click.pass_context
179+
def main(ctx: click.RichContext, **kwargs: Any) -> int:
180+
try:
181+
config = Settings.model_validate(kwargs)
182+
183+
# Replicate click's `no_args_is_help` behavior but only when config file does not contain feeds/OPMLs
184+
if not (config.feeds or config.opml_files):
185+
click.echo(ctx.command.get_help(ctx))
186+
return 0
187+
188+
pa = PodcastArchiver(config)
189+
pa.run()
190+
except KeyboardInterrupt as exc:
191+
raise click.Abort("Interrupted by user") from exc
192+
except FileNotFoundError as exc:
193+
raise click.Abort(exc) from exc
194+
except ValidationError as exc:
195+
raise click.Abort(f"Invalid settings: {exc}") from exc
196+
return 0
197+
198+
199+
if __name__ == "__main__":
200+
main.main(prog_name=PROG_NAME)

0 commit comments

Comments
 (0)