kiosque 0.1.0__py3-none-any.whl

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 (52) hide show
  1. kiosque/__init__.py +159 -0
  2. kiosque/__main__.py +17 -0
  3. kiosque/api/__init__.py +0 -0
  4. kiosque/api/github.py +236 -0
  5. kiosque/api/pocket.py +133 -0
  6. kiosque/api/raindrop.py +208 -0
  7. kiosque/core/__init__.py +0 -0
  8. kiosque/core/client.py +137 -0
  9. kiosque/core/config.py +246 -0
  10. kiosque/core/website.py +473 -0
  11. kiosque/tui/__init__.py +0 -0
  12. kiosque/tui/github.py +129 -0
  13. kiosque/tui/kiosque.tcss +117 -0
  14. kiosque/tui/raindrop.py +233 -0
  15. kiosque/tui/tui.py +622 -0
  16. kiosque/website/01net.py +10 -0
  17. kiosque/website/__init__.py +0 -0
  18. kiosque/website/asahi.py +24 -0
  19. kiosque/website/aviationweek.py +149 -0
  20. kiosque/website/courrierinternational.py +99 -0
  21. kiosque/website/franceculture.py +26 -0
  22. kiosque/website/francetvinfo.py +19 -0
  23. kiosque/website/ft.py +9 -0
  24. kiosque/website/ladepeche.py +22 -0
  25. kiosque/website/latimes.py +24 -0
  26. kiosque/website/lefigaro.py +20 -0
  27. kiosque/website/lemonde.py +105 -0
  28. kiosque/website/lesechos.py +74 -0
  29. kiosque/website/letemps.py +21 -0
  30. kiosque/website/mediapart.py +38 -0
  31. kiosque/website/mondediplomatique.py +93 -0
  32. kiosque/website/nationalgeographic.py +30 -0
  33. kiosque/website/nbcnews.py +24 -0
  34. kiosque/website/newyorker.py +33 -0
  35. kiosque/website/nikkei.py +19 -0
  36. kiosque/website/nytimes.py +167 -0
  37. kiosque/website/policito.py +56 -0
  38. kiosque/website/pourlascience.py +74 -0
  39. kiosque/website/quantamagazine.py +20 -0
  40. kiosque/website/reporterre.py +39 -0
  41. kiosque/website/rugbyrama.py +31 -0
  42. kiosque/website/telerama.py +10 -0
  43. kiosque/website/theatlantic.py +18 -0
  44. kiosque/website/theconversation.py +15 -0
  45. kiosque/website/theguardian.py +15 -0
  46. kiosque/website/usinenouvelle.py +145 -0
  47. kiosque/website/washingtonpost.py +30 -0
  48. kiosque/website/yomiuri.py +12 -0
  49. kiosque-0.1.0.dist-info/METADATA +173 -0
  50. kiosque-0.1.0.dist-info/RECORD +52 -0
  51. kiosque-0.1.0.dist-info/WHEEL +4 -0
  52. kiosque-0.1.0.dist-info/entry_points.txt +2 -0
kiosque/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ # from .aviationweek import AviationWeek
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from .core.config import config_dict, configuration_file # noqa: F401
10
+ from .core.website import Website
11
+ from .tui.tui import main as tui_main
12
+
13
+
14
+ def list_websites() -> None:
15
+ """List all supported websites with their authentication status."""
16
+ website_dir = Path(__file__).parent / "website"
17
+ websites_info = []
18
+
19
+ for module_file in sorted(website_dir.glob("*.py")):
20
+ if module_file.stem.startswith("_"):
21
+ continue
22
+
23
+ content = module_file.read_text()
24
+
25
+ # Extract base_url
26
+ base_url = None
27
+ for line in content.split("\n"):
28
+ if "base_url = " in line and "http" in line:
29
+ # Extract URL from string
30
+ base_url = (
31
+ line.split('"')[1] if '"' in line else line.split("'")[1]
32
+ )
33
+ break
34
+
35
+ # Check if login_url is defined (indicates auth required)
36
+ needs_auth = "login_url = " in content
37
+
38
+ # Extract class name for display
39
+ class_name = None
40
+ for line in content.split("\n"):
41
+ if line.startswith("class ") and "(Website)" in line:
42
+ class_name = line.split("class ")[1].split("(")[0]
43
+ break
44
+
45
+ if base_url and class_name:
46
+ websites_info.append(
47
+ {
48
+ "name": class_name,
49
+ "url": base_url,
50
+ "auth": "Yes" if needs_auth else "No",
51
+ }
52
+ )
53
+
54
+ # Print header
55
+ click.echo("\nSupported Websites:")
56
+ click.echo("=" * 80)
57
+ click.echo(f"{'Website':<30} {'Authentication':<15} {'URL':<35}")
58
+ click.echo("-" * 80)
59
+
60
+ # Print websites
61
+ for info in websites_info:
62
+ click.echo(f"{info['name']:<30} {info['auth']:<15} {info['url']:<35}")
63
+
64
+ click.echo("=" * 80)
65
+ click.echo(f"\nTotal: {len(websites_info)} websites supported\n")
66
+
67
+
68
+ @click.command(
69
+ help="Read newspaper articles in textual format. Launches TUI by default."
70
+ )
71
+ @click.argument("url_or_alias", required=False)
72
+ @click.argument("output", type=click.File("w"), required=False, default=None)
73
+ @click.option("-v", "--verbose", count=True, help="Verbosity level")
74
+ @click.option(
75
+ "--list-websites",
76
+ "show_list",
77
+ is_flag=True,
78
+ help="List all supported websites",
79
+ )
80
+ def main(
81
+ url_or_alias: str | None,
82
+ output: Path | None,
83
+ verbose: int,
84
+ show_list: bool,
85
+ ) -> None:
86
+ # Handle --list-websites flag
87
+ if show_list:
88
+ list_websites()
89
+ return
90
+
91
+ # Launch TUI by default when no arguments provided
92
+ if url_or_alias is None:
93
+ tui_main()
94
+ return
95
+
96
+ # Configure logging
97
+ logging.basicConfig(
98
+ level=logging.WARNING,
99
+ format="%(levelname)s: %(message)s",
100
+ )
101
+ logger = logging.getLogger()
102
+
103
+ if verbose == 1:
104
+ logger.setLevel(logging.INFO)
105
+ elif verbose > 1:
106
+ logger.setLevel(logging.DEBUG)
107
+
108
+ if isinstance(output, str):
109
+ output = Path(output)
110
+
111
+ library: dict[str, type[Website]] = dict()
112
+
113
+ for key, value in config_dict.items():
114
+ if not key.startswith("http"):
115
+ continue
116
+
117
+ logging.debug(f"Parsing configuration for {key}")
118
+ if not key.endswith("/"):
119
+ key += "/"
120
+
121
+ library[key] = website = Website.instance(key).__class__
122
+
123
+ if "alias" in value:
124
+ for alias in value["alias"].split(","):
125
+ logging.debug(f"Setting alias '{alias}' for {website}")
126
+ website.alias.append(alias)
127
+
128
+ for alias in website.alias:
129
+ library[alias] = website
130
+
131
+ logging.debug(f"List of aliases: {library}")
132
+
133
+ try:
134
+ if url_or_alias == "tui":
135
+ tui_main()
136
+ elif url_or_alias in library:
137
+ library[url_or_alias]().save_latest_issue()
138
+ elif output is None or isinstance(output, Path):
139
+ instance = Website.instance(url_or_alias)
140
+ instance.write_text(url_or_alias, output)
141
+ else:
142
+ # If it should be written to -
143
+ instance = Website.instance(url_or_alias)
144
+ content = instance.full_text(url_or_alias)
145
+ output.write(content)
146
+ except ValueError as e:
147
+ logging.error(str(e))
148
+ raise click.ClickException(str(e))
149
+ except NotImplementedError as e:
150
+ logging.error(str(e))
151
+ raise click.ClickException(str(e))
152
+ except Exception as e:
153
+ logging.error(f"An unexpected error occurred: {e}")
154
+ if verbose > 0:
155
+ raise
156
+ else:
157
+ raise click.ClickException(
158
+ f"An error occurred. Use -v for more details: {e}"
159
+ )
kiosque/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ import os
2
+ import sys
3
+
4
+ # If we are running from a wheel, add the wheel to sys.path
5
+ # This allows the usage python pip-*.whl/pip install pip-*.whl
6
+ if __package__ == "":
7
+ # __file__ is kiosque-*.whl/kiosque/__main__.py
8
+ # first dirname call strips of '/__main__.py', second strips off '/pip'
9
+ # Resulting path is the name of the wheel itself
10
+ # Add that to sys.path so we can import pip
11
+ path = os.path.dirname(os.path.dirname(__file__))
12
+ sys.path.insert(0, path)
13
+
14
+ from kiosque import main
15
+
16
+ if __name__ == "__main__":
17
+ main()
File without changes
kiosque/api/github.py ADDED
@@ -0,0 +1,236 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ import httpx
5
+ from pydantic import BaseModel, Field, HttpUrl, field_validator
6
+
7
+ from ..core.config import config_dict
8
+
9
+
10
+ class GitHubRepo(BaseModel):
11
+ """Model representing a GitHub repository."""
12
+
13
+ id: int
14
+ name: str
15
+ full_name: str
16
+ html_url: HttpUrl
17
+ description: str | None = None
18
+ language: str | None = None
19
+ stargazers_count: int = 0
20
+ topics: list[str] = Field(default_factory=list)
21
+ created_at: datetime
22
+ updated_at: datetime
23
+ pushed_at: datetime
24
+ homepage: HttpUrl | None = None
25
+ owner: dict[str, Any] # Contains login, avatar_url, etc.
26
+
27
+ # Additional fields for starred repos
28
+ starred_at: datetime | None = None
29
+
30
+ @field_validator("homepage", mode="before")
31
+ @classmethod
32
+ def validate_homepage(cls, v: str | None) -> str | None:
33
+ """Validate homepage URL, converting empty strings to None."""
34
+ if v == "" or v is None:
35
+ return None
36
+ return v
37
+
38
+
39
+ class GitHubAPI:
40
+ """API client for interacting with GitHub.
41
+
42
+ Go to https://github.com/settings/tokens
43
+ Create a classic token with 'public_repo' or 'repo' scope
44
+ """
45
+
46
+ def __init__(self) -> None:
47
+ github_config = config_dict.get("github", None)
48
+ assert github_config is not None, "GitHub configuration not found."
49
+
50
+ self.token = github_config.get("token")
51
+ self.headers = {
52
+ "Authorization": f"Bearer {self.token}",
53
+ "Accept": "application/vnd.github+json",
54
+ "X-GitHub-Api-Version": "2022-11-28",
55
+ }
56
+ self.client = httpx.Client(headers=self.headers, timeout=30.0)
57
+ self.async_client = httpx.AsyncClient(
58
+ headers=self.headers, timeout=30.0
59
+ )
60
+
61
+ async def get_user_async(self) -> dict[str, Any]:
62
+ """Get the authenticated user's information."""
63
+ response = await self.async_client.get("https://api.github.com/user")
64
+ response.raise_for_status()
65
+ return response.json()
66
+
67
+ async def get_starred_repos_async(
68
+ self, page: int = 1, per_page: int = 100
69
+ ) -> list[GitHubRepo]:
70
+ """Get starred repositories for the authenticated user.
71
+
72
+ Args:
73
+ page: Page number (1-indexed)
74
+ per_page: Number of results per page (max 100)
75
+
76
+ Returns:
77
+ List of GitHubRepo objects
78
+ """
79
+ # Use the star timestamp header to get when repos were starred
80
+ headers = {
81
+ **self.headers,
82
+ "Accept": "application/vnd.github.star+json",
83
+ }
84
+ response = await self.async_client.get(
85
+ "https://api.github.com/user/starred",
86
+ params={"page": page, "per_page": per_page},
87
+ headers=headers,
88
+ )
89
+ response.raise_for_status()
90
+
91
+ repos = []
92
+ for item in response.json():
93
+ repo_data = item["repo"]
94
+ repo_data["starred_at"] = item["starred_at"]
95
+ repos.append(GitHubRepo(**repo_data))
96
+
97
+ return repos
98
+
99
+ async def get_all_starred_repos_async(self) -> list[GitHubRepo]:
100
+ """Get all starred repositories (handles pagination automatically)."""
101
+ all_repos = []
102
+ page = 1
103
+
104
+ while True:
105
+ repos = await self.get_starred_repos_async(page=page, per_page=100)
106
+ if not repos:
107
+ break
108
+ all_repos.extend(repos)
109
+ if len(repos) < 100: # Last page
110
+ break
111
+ page += 1
112
+
113
+ return all_repos
114
+
115
+ async def get_readme_async(self, owner: str, repo: str) -> str | None:
116
+ """Get the README content for a repository.
117
+
118
+ Args:
119
+ owner: Repository owner (username or organization)
120
+ repo: Repository name
121
+
122
+ Returns:
123
+ README content in markdown format, or None if not found
124
+ """
125
+ try:
126
+ response = await self.async_client.get(
127
+ f"https://api.github.com/repos/{owner}/{repo}/readme",
128
+ headers={
129
+ **self.headers,
130
+ "Accept": "application/vnd.github.raw+json",
131
+ },
132
+ )
133
+ response.raise_for_status()
134
+ return response.text
135
+ except httpx.HTTPStatusError as e:
136
+ if e.response.status_code == 404:
137
+ return None # No README found
138
+ raise
139
+
140
+ async def get_languages_async(
141
+ self, owner: str, repo: str
142
+ ) -> dict[str, int]:
143
+ """Get languages used in a repository.
144
+
145
+ Args:
146
+ owner: Repository owner
147
+ repo: Repository name
148
+
149
+ Returns:
150
+ Dictionary mapping language names to bytes of code
151
+ """
152
+ response = await self.async_client.get(
153
+ f"https://api.github.com/repos/{owner}/{repo}/languages"
154
+ )
155
+ response.raise_for_status()
156
+ return response.json()
157
+
158
+ async def unstar_repo_async(self, owner: str, repo: str) -> None:
159
+ """Unstar a repository.
160
+
161
+ Args:
162
+ owner: Repository owner
163
+ repo: Repository name
164
+ """
165
+ response = await self.async_client.delete(
166
+ f"https://api.github.com/user/starred/{owner}/{repo}"
167
+ )
168
+ response.raise_for_status()
169
+
170
+ async def star_repo_async(self, owner: str, repo: str) -> None:
171
+ """Star a repository.
172
+
173
+ Args:
174
+ owner: Repository owner
175
+ repo: Repository name
176
+ """
177
+ response = await self.async_client.put(
178
+ f"https://api.github.com/user/starred/{owner}/{repo}"
179
+ )
180
+ response.raise_for_status()
181
+
182
+ async def is_starred_async(self, owner: str, repo: str) -> bool:
183
+ """Check if a repository is starred.
184
+
185
+ Args:
186
+ owner: Repository owner
187
+ repo: Repository name
188
+
189
+ Returns:
190
+ True if starred, False otherwise
191
+ """
192
+ response = await self.async_client.get(
193
+ f"https://api.github.com/user/starred/{owner}/{repo}"
194
+ )
195
+ return response.status_code == 204
196
+
197
+ async def async_retrieve(self, offset: int = 0) -> dict[str, Any]:
198
+ """Legacy method for compatibility with TUI - returns formatted data.
199
+
200
+ Args:
201
+ offset: Offset for pagination (not used, GitHub API uses pages)
202
+
203
+ Returns:
204
+ Dict with 'list' (dict of repos by ID) and 'count'
205
+ """
206
+ repos = await self.get_all_starred_repos_async()
207
+
208
+ # Convert to format expected by TUI
209
+ items_dict = {str(repo.id): repo for repo in repos}
210
+ return {"list": items_dict, "count": len(repos)}
211
+
212
+ async def async_action(
213
+ self, action: str, repo_id: int, owner: str, repo: str
214
+ ) -> None:
215
+ """Perform an action on a repository.
216
+
217
+ Args:
218
+ action: Action to perform ('unstar', etc.)
219
+ repo_id: Repository ID (for compatibility)
220
+ owner: Repository owner
221
+ repo: Repository name
222
+ """
223
+ if action == "unstar":
224
+ await self.unstar_repo_async(owner, repo)
225
+ else:
226
+ raise ValueError(f"Unknown action: {action}")
227
+
228
+ def close(self) -> None:
229
+ """Close HTTP clients."""
230
+ self.client.close()
231
+ self.async_client.close()
232
+
233
+ async def aclose(self) -> None:
234
+ """Async close HTTP clients."""
235
+ await self.async_client.aclose()
236
+ self.client.close()
kiosque/api/pocket.py ADDED
@@ -0,0 +1,133 @@
1
+ from typing import Iterator, Literal, TypedDict
2
+
3
+ import httpx
4
+
5
+ from ..core.config import config_dict
6
+
7
+
8
+ class UnknownJSON(TypedDict): ...
9
+
10
+
11
+ class PocketRetrieveEntry(TypedDict):
12
+ item_id: str
13
+ resolved_id: str # useful for duplicates
14
+ given_url: str
15
+ resolved_url: str
16
+ given_title: str
17
+ resolved_title: str
18
+ favorite: Literal[0, 1]
19
+ status: Literal[0, 1, 2]
20
+ excerpt: str # articles only
21
+ is_article: Literal[0, 1]
22
+ has_image: Literal[0, 1, 2]
23
+ has_video: Literal[0, 1, 2]
24
+ word_count: str
25
+ tags: UnknownJSON
26
+ authors: UnknownJSON
27
+ images: UnknownJSON
28
+ videos: UnknownJSON
29
+ time_added: str
30
+
31
+
32
+ class PocketRetrieveResponse(TypedDict):
33
+ maxActions: int
34
+ status: Literal[0, 1]
35
+ list: dict[str, PocketRetrieveEntry]
36
+
37
+
38
+ class PocketAPI:
39
+ json: PocketRetrieveResponse
40
+
41
+ def __init__(self):
42
+ pocket_config = config_dict.get("getpocket.com", None)
43
+ assert pocket_config is not None
44
+
45
+ self.consumer_key = pocket_config.get("consumer_key")
46
+ self.access_token = pocket_config.get("access_token")
47
+ self.client = httpx.Client()
48
+ self.async_client = httpx.AsyncClient()
49
+
50
+ def __len__(self):
51
+ return len(self.json["list"])
52
+
53
+ def __getitem__(self, select):
54
+ for i, elt in enumerate(self.json["list"].values()):
55
+ if i == select:
56
+ return elt
57
+
58
+ def __iter__(self) -> Iterator[PocketRetrieveEntry]:
59
+ yield from self.json["list"].values()
60
+
61
+ def retrieve(self, offset=30) -> PocketRetrieveResponse:
62
+ # https://getpocket.com/developer/docs/v3/retrieve
63
+ r = self.client.post(
64
+ "https://getpocket.com/v3/get",
65
+ json={
66
+ "consumer_key": self.consumer_key,
67
+ "access_token": self.access_token,
68
+ "count": 30,
69
+ "offset": offset,
70
+ "total": 1,
71
+ "sort": "newest",
72
+ },
73
+ headers={
74
+ "Content-Type": "application/json",
75
+ "X-Accept": "application/json",
76
+ },
77
+ )
78
+ r.raise_for_status()
79
+ self.json = r.json()
80
+ return self.json
81
+
82
+ async def async_retrieve(self, offset=0) -> PocketRetrieveResponse:
83
+ r = await self.async_client.post(
84
+ "https://getpocket.com/v3/get",
85
+ json={
86
+ "consumer_key": self.consumer_key,
87
+ "access_token": self.access_token,
88
+ "count": 30,
89
+ "offset": offset,
90
+ "total": 1,
91
+ "sort": "newest",
92
+ "state": "unread",
93
+ },
94
+ headers={
95
+ "Content-Type": "application/json",
96
+ "X-Accept": "application/json",
97
+ },
98
+ )
99
+ r.raise_for_status()
100
+ self.json = r.json()
101
+ return self.json
102
+
103
+ def action(self, action: str, item_id: str):
104
+ r = self.client.post(
105
+ "https://getpocket.com/v3/send",
106
+ json={
107
+ "consumer_key": self.consumer_key,
108
+ "access_token": self.access_token,
109
+ "actions": [{"action": action, "item_id": item_id}],
110
+ },
111
+ headers={
112
+ "Content-Type": "application/json",
113
+ "X-Accept": "application/json",
114
+ },
115
+ )
116
+ r.raise_for_status()
117
+ return r.json()
118
+
119
+ async def async_action(self, action: str, item_id: str):
120
+ r = await self.async_client.post(
121
+ "https://getpocket.com/v3/send",
122
+ json={
123
+ "consumer_key": self.consumer_key,
124
+ "access_token": self.access_token,
125
+ "actions": [{"action": action, "item_id": item_id}],
126
+ },
127
+ headers={
128
+ "Content-Type": "application/json",
129
+ "X-Accept": "application/json",
130
+ },
131
+ )
132
+ r.raise_for_status()
133
+ return r.json()