linkedin-post-cli 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.
- linkedin_post_cli-0.1.0/.claude/settings.local.json +8 -0
- linkedin_post_cli-0.1.0/.gitignore +7 -0
- linkedin_post_cli-0.1.0/PKG-INFO +5 -0
- linkedin_post_cli-0.1.0/README.md +50 -0
- linkedin_post_cli-0.1.0/pyproject.toml +24 -0
- linkedin_post_cli-0.1.0/src/linkedin_post/__init__.py +0 -0
- linkedin_post_cli-0.1.0/src/linkedin_post/auth.py +105 -0
- linkedin_post_cli-0.1.0/src/linkedin_post/cli.py +135 -0
- linkedin_post_cli-0.1.0/src/linkedin_post/client.py +141 -0
- linkedin_post_cli-0.1.0/src/linkedin_post/config.py +88 -0
- linkedin_post_cli-0.1.0/tests/__init__.py +0 -0
- linkedin_post_cli-0.1.0/tests/helpers.py +25 -0
- linkedin_post_cli-0.1.0/tests/test_client.py +333 -0
- linkedin_post_cli-0.1.0/tests/test_config.py +92 -0
- linkedin_post_cli-0.1.0/uv.lock +198 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# linkedin-post-cli
|
|
2
|
+
|
|
3
|
+
CLI utility for publishing posts to LinkedIn via the official API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uvx linkedin-post-cli@latest "Hello world!" # run directly
|
|
9
|
+
uv tool install linkedin-post-cli # install globally
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Prerequisites
|
|
13
|
+
|
|
14
|
+
1. Create a LinkedIn app at https://www.linkedin.com/developers/apps/new (requires a Company Page)
|
|
15
|
+
2. Add products: **Sign In with LinkedIn using OpenID Connect** and **Share on LinkedIn**
|
|
16
|
+
3. In **Auth** → Redirect URLs, add: `http://localhost:8000/callback`
|
|
17
|
+
4. Copy `Client ID` and `Client Secret` — you'll be prompted on first run
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Inline text
|
|
23
|
+
linkedin-post-cli "My first post via API!"
|
|
24
|
+
|
|
25
|
+
# From file
|
|
26
|
+
linkedin-post-cli --from-file draft.txt
|
|
27
|
+
|
|
28
|
+
# Interactive input (multiline, Ctrl+D to send)
|
|
29
|
+
linkedin-post-cli
|
|
30
|
+
|
|
31
|
+
# Visible to connections only
|
|
32
|
+
linkedin-post-cli --connections-only "Private update"
|
|
33
|
+
|
|
34
|
+
# Attach an image
|
|
35
|
+
linkedin-post-cli --image photo.jpg "Check this out!"
|
|
36
|
+
|
|
37
|
+
# Re-authorize (clear OAuth token)
|
|
38
|
+
linkedin-post-cli --reset-auth "Hello again"
|
|
39
|
+
|
|
40
|
+
# Clear all saved credentials
|
|
41
|
+
linkedin-post-cli --reset-keys "Starting over"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On first run, you'll be prompted for Client ID and Client Secret, then a browser window opens for OAuth authorization. Credentials are saved to `~/.config/linkedin-post-cli/config.json`.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv run pytest -v
|
|
50
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "linkedin-post-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = ["requests"]
|
|
6
|
+
|
|
7
|
+
[project.scripts]
|
|
8
|
+
linkedin-post-cli = "linkedin_post.cli:main"
|
|
9
|
+
|
|
10
|
+
[tool.pytest.ini_options]
|
|
11
|
+
testpaths = ["tests"]
|
|
12
|
+
pythonpath = ["tests"]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=9.0.2",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/linkedin_post"]
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OAuth 2.0 authentication flow for LinkedIn."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
_AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
|
|
12
|
+
_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
|
|
13
|
+
_USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
|
|
14
|
+
_REDIRECT_URI = "http://localhost:8000/callback"
|
|
15
|
+
_SCOPES = "openid profile w_member_social"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_token_valid(token: str) -> bool:
|
|
19
|
+
"""Check whether *token* is still accepted by LinkedIn API."""
|
|
20
|
+
resp = requests.get(
|
|
21
|
+
_USERINFO_URL,
|
|
22
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
23
|
+
timeout=10,
|
|
24
|
+
)
|
|
25
|
+
return resp.status_code == 200
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def authenticate(client_id: str, client_secret: str) -> str:
|
|
29
|
+
"""Run the full OAuth browser flow and return an access token.
|
|
30
|
+
|
|
31
|
+
Opens the default browser for user consent, starts a temporary
|
|
32
|
+
local HTTP server to capture the redirect, and exchanges the
|
|
33
|
+
authorization code for a token.
|
|
34
|
+
"""
|
|
35
|
+
auth_code: str | None = None
|
|
36
|
+
error: str | None = None
|
|
37
|
+
server_ready = threading.Event()
|
|
38
|
+
|
|
39
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
40
|
+
def do_GET(self) -> None: # noqa: N802
|
|
41
|
+
nonlocal auth_code, error
|
|
42
|
+
params = urllib.parse.parse_qs(
|
|
43
|
+
urllib.parse.urlparse(self.path).query
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if "code" in params:
|
|
47
|
+
auth_code = params["code"][0]
|
|
48
|
+
self._respond("Authorization successful! You can close this tab.")
|
|
49
|
+
else:
|
|
50
|
+
error = params.get("error", ["unknown"])[0]
|
|
51
|
+
self._respond(f"Authorization failed: {error}")
|
|
52
|
+
|
|
53
|
+
# Shut down server from a separate thread to avoid deadlock.
|
|
54
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
55
|
+
|
|
56
|
+
def _respond(self, message: str) -> None:
|
|
57
|
+
self.send_response(200)
|
|
58
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
59
|
+
self.end_headers()
|
|
60
|
+
self.wfile.write(
|
|
61
|
+
f"<html><body><h2>{message}</h2></body></html>".encode()
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
65
|
+
pass # silence request logs
|
|
66
|
+
|
|
67
|
+
server = http.server.HTTPServer(("localhost", 8000), _CallbackHandler)
|
|
68
|
+
|
|
69
|
+
def _serve() -> None:
|
|
70
|
+
server_ready.set()
|
|
71
|
+
server.serve_forever()
|
|
72
|
+
|
|
73
|
+
thread = threading.Thread(target=_serve, daemon=True)
|
|
74
|
+
thread.start()
|
|
75
|
+
server_ready.wait()
|
|
76
|
+
|
|
77
|
+
params = urllib.parse.urlencode({
|
|
78
|
+
"response_type": "code",
|
|
79
|
+
"client_id": client_id,
|
|
80
|
+
"redirect_uri": _REDIRECT_URI,
|
|
81
|
+
"scope": _SCOPES,
|
|
82
|
+
})
|
|
83
|
+
authorization_url = f"{_AUTH_URL}?{params}"
|
|
84
|
+
print(f"Opening browser for authorization...\n{authorization_url}")
|
|
85
|
+
webbrowser.open(authorization_url)
|
|
86
|
+
|
|
87
|
+
thread.join() # blocks until callback arrives
|
|
88
|
+
|
|
89
|
+
if error or auth_code is None:
|
|
90
|
+
print(f"Authorization failed: {error}", file=sys.stderr)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
token_resp = requests.post(
|
|
94
|
+
_TOKEN_URL,
|
|
95
|
+
data={
|
|
96
|
+
"grant_type": "authorization_code",
|
|
97
|
+
"code": auth_code,
|
|
98
|
+
"redirect_uri": _REDIRECT_URI,
|
|
99
|
+
"client_id": client_id,
|
|
100
|
+
"client_secret": client_secret,
|
|
101
|
+
},
|
|
102
|
+
timeout=30,
|
|
103
|
+
)
|
|
104
|
+
token_resp.raise_for_status()
|
|
105
|
+
return token_resp.json()["access_token"]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""CLI entry-point for linkedin-post-cli."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from linkedin_post.auth import authenticate, is_token_valid
|
|
8
|
+
from linkedin_post.client import LinkedInClient
|
|
9
|
+
from linkedin_post.config import ConfigStore, JsonConfigStore, prompt_if_missing
|
|
10
|
+
|
|
11
|
+
_MAX_LENGTH = 3000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description="Publish a text post to LinkedIn.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("text", nargs="?", help="Post text (inline)")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--from-file", type=pathlib.Path, help="Read post text from a file",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--connections-only",
|
|
24
|
+
action="store_true",
|
|
25
|
+
help="Visible to 1st-degree connections only (default: public)",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--image",
|
|
29
|
+
type=pathlib.Path,
|
|
30
|
+
metavar="PATH",
|
|
31
|
+
help="Attach an image (jpg/png/gif, max 100 MB)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--reset-auth",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Clear saved OAuth token and re-authorize",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--reset-keys",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="Clear all saved credentials and re-prompt from scratch",
|
|
42
|
+
)
|
|
43
|
+
return parser.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_post_text(args: argparse.Namespace) -> str:
|
|
47
|
+
if args.text:
|
|
48
|
+
return args.text
|
|
49
|
+
if args.from_file:
|
|
50
|
+
return args.from_file.read_text(encoding="utf-8").strip()
|
|
51
|
+
print("Enter post text (Ctrl+D to send):")
|
|
52
|
+
return sys.stdin.read().strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ensure_token(
|
|
56
|
+
config: ConfigStore,
|
|
57
|
+
client_id: str,
|
|
58
|
+
client_secret: str,
|
|
59
|
+
*,
|
|
60
|
+
force: bool,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Return a valid access token, running OAuth if needed."""
|
|
63
|
+
access_token = config.get("access_token")
|
|
64
|
+
|
|
65
|
+
if not force and access_token and is_token_valid(access_token):
|
|
66
|
+
return access_token
|
|
67
|
+
|
|
68
|
+
token = authenticate(client_id, client_secret)
|
|
69
|
+
config.set("access_token", token)
|
|
70
|
+
return token
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: list[str] | None = None, *, _config: ConfigStore | None = None) -> None:
|
|
74
|
+
args = _parse_args(argv)
|
|
75
|
+
config = _config or JsonConfigStore()
|
|
76
|
+
|
|
77
|
+
if args.reset_keys:
|
|
78
|
+
config.remove(["client_id", "client_secret", "access_token"])
|
|
79
|
+
elif args.reset_auth:
|
|
80
|
+
config.remove(["access_token"])
|
|
81
|
+
|
|
82
|
+
if not config.get("client_id"):
|
|
83
|
+
print(
|
|
84
|
+
"\n"
|
|
85
|
+
"First-time setup\n"
|
|
86
|
+
"================\n"
|
|
87
|
+
"You need OAuth 2.0 credentials from the LinkedIn Developer Portal.\n"
|
|
88
|
+
"\n"
|
|
89
|
+
"1. Create an app at https://www.linkedin.com/developers/apps/new\n"
|
|
90
|
+
" (requires a Company Page)\n"
|
|
91
|
+
"2. Add products: Sign In with LinkedIn using OpenID Connect\n"
|
|
92
|
+
" and Share on LinkedIn\n"
|
|
93
|
+
"3. In Auth → Redirect URLs, add: http://localhost:8000/callback\n"
|
|
94
|
+
"4. Copy the Client ID and Client Secret below\n",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
client_id = prompt_if_missing(config, "client_id", "Client ID")
|
|
98
|
+
client_secret = prompt_if_missing(config, "client_secret", "Client Secret")
|
|
99
|
+
|
|
100
|
+
text = _read_post_text(args)
|
|
101
|
+
if not text:
|
|
102
|
+
print("Empty post text, aborting.", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
if len(text) > _MAX_LENGTH:
|
|
105
|
+
print(
|
|
106
|
+
f"Post too long: {len(text)}/{_MAX_LENGTH} characters.",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
access_token = _ensure_token(
|
|
112
|
+
config, client_id, client_secret,
|
|
113
|
+
force=args.reset_auth,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
client = LinkedInClient(access_token)
|
|
117
|
+
|
|
118
|
+
image_urn = None
|
|
119
|
+
if args.image:
|
|
120
|
+
if not args.image.exists():
|
|
121
|
+
print(f"Image not found: {args.image}", file=sys.stderr)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
try:
|
|
124
|
+
image_urn = client.upload_image(args.image)
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
print(str(exc), file=sys.stderr)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
post_urn = client.create_post(
|
|
130
|
+
text, connections_only=args.connections_only, image_urn=image_urn,
|
|
131
|
+
)
|
|
132
|
+
post_url = f"https://www.linkedin.com/feed/update/{post_urn}/"
|
|
133
|
+
visibility = "connections only" if args.connections_only else "public"
|
|
134
|
+
print(f"Post published ({visibility})!")
|
|
135
|
+
print(post_url)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""LinkedIn API client for creating posts."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
_BASE_URL = "https://api.linkedin.com"
|
|
9
|
+
_LINKEDIN_VERSION = "202504"
|
|
10
|
+
_SUPPORTED_IMAGE_TYPES = {".jpg", ".jpeg", ".png", ".gif"}
|
|
11
|
+
_MAX_IMAGE_SIZE = 100 * 1024 * 1024 # 100 MB — LinkedIn documented limit
|
|
12
|
+
|
|
13
|
+
_CONTENT_TYPES = {
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LinkedInAPI(Protocol):
|
|
22
|
+
"""Interface for LinkedIn API operations."""
|
|
23
|
+
|
|
24
|
+
def get_user_id(self) -> str:
|
|
25
|
+
"""Return the authenticated user's person ID."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def upload_image(self, path: pathlib.Path) -> str:
|
|
29
|
+
"""Upload an image and return the image URN."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def create_post(
|
|
33
|
+
self, text: str, *, connections_only: bool = False, image_urn: str | None = None
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Publish a post and return the post URN."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LinkedInClient:
|
|
40
|
+
"""HTTP client for LinkedIn REST API.
|
|
41
|
+
|
|
42
|
+
Usage::
|
|
43
|
+
|
|
44
|
+
client = LinkedInClient(access_token="...")
|
|
45
|
+
post_urn = client.create_post("Hello LinkedIn!")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, access_token: str) -> None:
|
|
49
|
+
self._token = access_token
|
|
50
|
+
self._session = requests.Session()
|
|
51
|
+
self._session.headers.update({
|
|
52
|
+
"Authorization": f"Bearer {access_token}",
|
|
53
|
+
})
|
|
54
|
+
self._user_id: str | None = None
|
|
55
|
+
|
|
56
|
+
def get_user_id(self) -> str:
|
|
57
|
+
"""Return the authenticated user's person ID (``sub`` claim)."""
|
|
58
|
+
if self._user_id is None:
|
|
59
|
+
resp = self._session.get(f"{_BASE_URL}/v2/userinfo")
|
|
60
|
+
resp.raise_for_status()
|
|
61
|
+
self._user_id = resp.json()["sub"]
|
|
62
|
+
return self._user_id
|
|
63
|
+
|
|
64
|
+
def upload_image(self, path: pathlib.Path) -> str:
|
|
65
|
+
"""Upload an image file and return the image URN.
|
|
66
|
+
|
|
67
|
+
Raises ``ValueError`` for unsupported formats or oversized files.
|
|
68
|
+
"""
|
|
69
|
+
suffix = path.suffix.lower()
|
|
70
|
+
if suffix not in _SUPPORTED_IMAGE_TYPES:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Unsupported image format '{suffix}'. "
|
|
73
|
+
f"Supported: {', '.join(sorted(_SUPPORTED_IMAGE_TYPES))}"
|
|
74
|
+
)
|
|
75
|
+
file_size = path.stat().st_size
|
|
76
|
+
if file_size > _MAX_IMAGE_SIZE:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Image too large: {file_size} bytes "
|
|
79
|
+
f"(max {_MAX_IMAGE_SIZE} bytes / 100 MB)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
owner = f"urn:li:person:{self.get_user_id()}"
|
|
83
|
+
init_resp = self._session.post(
|
|
84
|
+
f"{_BASE_URL}/rest/images?action=initializeUpload",
|
|
85
|
+
json={"initializeUploadRequest": {"owner": owner}},
|
|
86
|
+
headers={
|
|
87
|
+
"LinkedIn-Version": _LINKEDIN_VERSION,
|
|
88
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
if not init_resp.ok:
|
|
92
|
+
raise requests.HTTPError(
|
|
93
|
+
f"{init_resp.status_code}: {init_resp.text}", response=init_resp
|
|
94
|
+
)
|
|
95
|
+
data = init_resp.json()["value"]
|
|
96
|
+
upload_url = data["uploadUrl"]
|
|
97
|
+
image_urn = data["image"]
|
|
98
|
+
|
|
99
|
+
put_resp = self._session.put(
|
|
100
|
+
upload_url,
|
|
101
|
+
data=path.read_bytes(),
|
|
102
|
+
headers={"Content-Type": _CONTENT_TYPES[suffix]},
|
|
103
|
+
)
|
|
104
|
+
if not put_resp.ok:
|
|
105
|
+
raise requests.HTTPError(
|
|
106
|
+
f"{put_resp.status_code}: {put_resp.text}", response=put_resp
|
|
107
|
+
)
|
|
108
|
+
return image_urn
|
|
109
|
+
|
|
110
|
+
def create_post(
|
|
111
|
+
self, text: str, *, connections_only: bool = False, image_urn: str | None = None
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Publish a post, optionally with an image. Returns the created post URN."""
|
|
114
|
+
author = f"urn:li:person:{self.get_user_id()}"
|
|
115
|
+
visibility = "CONNECTIONS" if connections_only else "PUBLIC"
|
|
116
|
+
body: dict = {
|
|
117
|
+
"author": author,
|
|
118
|
+
"lifecycleState": "PUBLISHED",
|
|
119
|
+
"visibility": visibility,
|
|
120
|
+
"commentary": text,
|
|
121
|
+
"distribution": {
|
|
122
|
+
"feedDistribution": "MAIN_FEED",
|
|
123
|
+
"targetEntities": [],
|
|
124
|
+
"thirdPartyDistributionChannels": [],
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
if image_urn is not None:
|
|
128
|
+
body["content"] = {"media": {"id": image_urn}}
|
|
129
|
+
resp = self._session.post(
|
|
130
|
+
f"{_BASE_URL}/rest/posts",
|
|
131
|
+
json=body,
|
|
132
|
+
headers={
|
|
133
|
+
"LinkedIn-Version": _LINKEDIN_VERSION,
|
|
134
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
if not resp.ok:
|
|
138
|
+
raise requests.HTTPError(
|
|
139
|
+
f"{resp.status_code}: {resp.text}", response=resp
|
|
140
|
+
)
|
|
141
|
+
return resp.headers.get("x-restli-id", "")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Persistent JSON config store for credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Callable, Protocol
|
|
10
|
+
|
|
11
|
+
_DEFAULT_PATH = pathlib.Path.home() / ".config" / "linkedin-post-cli" / "config.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigStore(Protocol):
|
|
15
|
+
"""Read/write access to persistent key-value config."""
|
|
16
|
+
|
|
17
|
+
def get(self, key: str) -> str | None: ...
|
|
18
|
+
def set(self, key: str, value: str) -> None: ...
|
|
19
|
+
def set_many(self, items: dict[str, str]) -> None: ...
|
|
20
|
+
def remove(self, keys: list[str]) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonConfigStore:
|
|
24
|
+
"""Config store backed by a JSON file (default ``~/.config/linkedin-post-cli/config.json``).
|
|
25
|
+
|
|
26
|
+
Creates the directory and file on first write. Sets ``0o600`` permissions
|
|
27
|
+
after each write to protect secrets.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: pathlib.Path = _DEFAULT_PATH) -> None:
|
|
31
|
+
self._path = path
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> str | None:
|
|
34
|
+
data = self._read()
|
|
35
|
+
return data.get(key)
|
|
36
|
+
|
|
37
|
+
def set(self, key: str, value: str) -> None:
|
|
38
|
+
data = self._read()
|
|
39
|
+
data[key] = value
|
|
40
|
+
self._write(data)
|
|
41
|
+
|
|
42
|
+
def set_many(self, items: dict[str, str]) -> None:
|
|
43
|
+
data = self._read()
|
|
44
|
+
data.update(items)
|
|
45
|
+
self._write(data)
|
|
46
|
+
|
|
47
|
+
def remove(self, keys: list[str]) -> None:
|
|
48
|
+
data = self._read()
|
|
49
|
+
for k in keys:
|
|
50
|
+
data.pop(k, None)
|
|
51
|
+
self._write(data)
|
|
52
|
+
|
|
53
|
+
def _read(self) -> dict[str, str]:
|
|
54
|
+
if not self._path.exists():
|
|
55
|
+
return {}
|
|
56
|
+
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
def _write(self, data: dict[str, str]) -> None:
|
|
59
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._path.write_text(
|
|
61
|
+
json.dumps(data, indent=2) + "\n", encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
os.chmod(self._path, 0o600)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def prompt_if_missing(
|
|
67
|
+
config: ConfigStore,
|
|
68
|
+
key: str,
|
|
69
|
+
display_name: str,
|
|
70
|
+
*,
|
|
71
|
+
prompt_fn: Callable[[str], str] | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Return the value for *key*, prompting the user if it's not stored yet.
|
|
74
|
+
|
|
75
|
+
Saves the prompted value into *config* for next time.
|
|
76
|
+
Exits if the user provides an empty value.
|
|
77
|
+
"""
|
|
78
|
+
value = config.get(key)
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
_prompt = prompt_fn or input
|
|
82
|
+
value = _prompt(f"{display_name}: ")
|
|
83
|
+
if not value or not value.strip():
|
|
84
|
+
print("Value required. Aborting.", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
value = value.strip()
|
|
87
|
+
config.set(key, value)
|
|
88
|
+
return value
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared test utilities."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DictConfigStore:
|
|
5
|
+
"""In-memory ConfigStore for tests."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, data: dict[str, str] | None = None) -> None:
|
|
8
|
+
self._data: dict[str, str] = dict(data) if data else {}
|
|
9
|
+
|
|
10
|
+
def get(self, key: str) -> str | None:
|
|
11
|
+
return self._data.get(key)
|
|
12
|
+
|
|
13
|
+
def set(self, key: str, value: str) -> None:
|
|
14
|
+
self._data[key] = value
|
|
15
|
+
|
|
16
|
+
def set_many(self, items: dict[str, str]) -> None:
|
|
17
|
+
self._data.update(items)
|
|
18
|
+
|
|
19
|
+
def remove(self, keys: list[str]) -> None:
|
|
20
|
+
for k in keys:
|
|
21
|
+
self._data.pop(k, None)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def data(self) -> dict[str, str]:
|
|
25
|
+
return self._data
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Unit tests for LinkedInClient and CLI."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from linkedin_post.cli import main
|
|
9
|
+
from linkedin_post.client import LinkedInClient, _MAX_IMAGE_SIZE
|
|
10
|
+
|
|
11
|
+
from helpers import DictConfigStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def client() -> LinkedInClient:
|
|
16
|
+
return LinkedInClient(access_token="fake-token")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestGetUserId:
|
|
20
|
+
def test_returns_sub_from_userinfo(self, client: LinkedInClient) -> None:
|
|
21
|
+
with patch.object(client._session, "get") as mock_get:
|
|
22
|
+
mock_get.return_value = _ok_response({"sub": "abc123"})
|
|
23
|
+
assert client.get_user_id() == "abc123"
|
|
24
|
+
|
|
25
|
+
def test_caches_user_id(self, client: LinkedInClient) -> None:
|
|
26
|
+
with patch.object(client._session, "get") as mock_get:
|
|
27
|
+
mock_get.return_value = _ok_response({"sub": "abc123"})
|
|
28
|
+
client.get_user_id()
|
|
29
|
+
client.get_user_id()
|
|
30
|
+
mock_get.assert_called_once()
|
|
31
|
+
|
|
32
|
+
def test_raises_on_http_error(self, client: LinkedInClient) -> None:
|
|
33
|
+
with patch.object(client._session, "get") as mock_get:
|
|
34
|
+
mock_get.return_value = _error_response(401)
|
|
35
|
+
with pytest.raises(Exception):
|
|
36
|
+
client.get_user_id()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestCreatePost:
|
|
40
|
+
def test_sends_correct_body(self, client: LinkedInClient) -> None:
|
|
41
|
+
with (
|
|
42
|
+
patch.object(
|
|
43
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
44
|
+
),
|
|
45
|
+
patch.object(client._session, "post") as mock_post,
|
|
46
|
+
):
|
|
47
|
+
mock_post.return_value = _created_response("urn:li:share:123")
|
|
48
|
+
client.create_post("Hello!")
|
|
49
|
+
|
|
50
|
+
body = mock_post.call_args.kwargs["json"]
|
|
51
|
+
assert body["author"] == "urn:li:person:u1"
|
|
52
|
+
assert body["commentary"] == "Hello!"
|
|
53
|
+
assert body["lifecycleState"] == "PUBLISHED"
|
|
54
|
+
assert body["visibility"] == "PUBLIC"
|
|
55
|
+
|
|
56
|
+
def test_sends_required_headers(self, client: LinkedInClient) -> None:
|
|
57
|
+
with (
|
|
58
|
+
patch.object(
|
|
59
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
60
|
+
),
|
|
61
|
+
patch.object(client._session, "post") as mock_post,
|
|
62
|
+
):
|
|
63
|
+
mock_post.return_value = _created_response("urn:li:share:123")
|
|
64
|
+
client.create_post("Hi")
|
|
65
|
+
|
|
66
|
+
headers = mock_post.call_args.kwargs["headers"]
|
|
67
|
+
assert headers["LinkedIn-Version"] == "202504"
|
|
68
|
+
assert headers["X-Restli-Protocol-Version"] == "2.0.0"
|
|
69
|
+
|
|
70
|
+
def test_returns_post_urn(self, client: LinkedInClient) -> None:
|
|
71
|
+
with (
|
|
72
|
+
patch.object(
|
|
73
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
74
|
+
),
|
|
75
|
+
patch.object(client._session, "post") as mock_post,
|
|
76
|
+
):
|
|
77
|
+
mock_post.return_value = _created_response("urn:li:share:456")
|
|
78
|
+
assert client.create_post("test") == "urn:li:share:456"
|
|
79
|
+
|
|
80
|
+
def test_connections_only_sets_visibility(self, client: LinkedInClient) -> None:
|
|
81
|
+
with (
|
|
82
|
+
patch.object(
|
|
83
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
84
|
+
),
|
|
85
|
+
patch.object(client._session, "post") as mock_post,
|
|
86
|
+
):
|
|
87
|
+
mock_post.return_value = _created_response("urn:li:share:789")
|
|
88
|
+
client.create_post("Private", connections_only=True)
|
|
89
|
+
|
|
90
|
+
body = mock_post.call_args.kwargs["json"]
|
|
91
|
+
assert body["visibility"] == "CONNECTIONS"
|
|
92
|
+
|
|
93
|
+
def test_raises_on_api_error(self, client: LinkedInClient) -> None:
|
|
94
|
+
with (
|
|
95
|
+
patch.object(
|
|
96
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
97
|
+
),
|
|
98
|
+
patch.object(client._session, "post") as mock_post,
|
|
99
|
+
):
|
|
100
|
+
mock_post.return_value = _error_response(403)
|
|
101
|
+
with pytest.raises(Exception):
|
|
102
|
+
client.create_post("nope")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestUploadImage:
|
|
106
|
+
def test_returns_image_urn(self, client: LinkedInClient, tmp_path: pathlib.Path) -> None:
|
|
107
|
+
img = tmp_path / "photo.png"
|
|
108
|
+
img.write_bytes(b"\x89PNG" + b"\x00" * 100)
|
|
109
|
+
|
|
110
|
+
init_resp = _ok_response({
|
|
111
|
+
"value": {
|
|
112
|
+
"uploadUrl": "https://linkedin.com/upload/xyz",
|
|
113
|
+
"image": "urn:li:image:C123",
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
put_resp = MagicMock(ok=True)
|
|
117
|
+
|
|
118
|
+
with (
|
|
119
|
+
patch.object(
|
|
120
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
121
|
+
),
|
|
122
|
+
patch.object(client._session, "post", return_value=init_resp),
|
|
123
|
+
patch.object(client._session, "put", return_value=put_resp),
|
|
124
|
+
):
|
|
125
|
+
urn = client.upload_image(img)
|
|
126
|
+
|
|
127
|
+
assert urn == "urn:li:image:C123"
|
|
128
|
+
|
|
129
|
+
def test_rejects_unsupported_format(
|
|
130
|
+
self, client: LinkedInClient, tmp_path: pathlib.Path
|
|
131
|
+
) -> None:
|
|
132
|
+
img = tmp_path / "photo.bmp"
|
|
133
|
+
img.write_bytes(b"\x00" * 10)
|
|
134
|
+
|
|
135
|
+
with pytest.raises(ValueError, match="Unsupported image format"):
|
|
136
|
+
client.upload_image(img)
|
|
137
|
+
|
|
138
|
+
def test_rejects_oversized_file(
|
|
139
|
+
self, client: LinkedInClient, tmp_path: pathlib.Path
|
|
140
|
+
) -> None:
|
|
141
|
+
img = tmp_path / "huge.png"
|
|
142
|
+
img.write_bytes(b"\x00" * (_MAX_IMAGE_SIZE + 1))
|
|
143
|
+
|
|
144
|
+
with pytest.raises(ValueError, match="Image too large"):
|
|
145
|
+
client.upload_image(img)
|
|
146
|
+
|
|
147
|
+
def test_raises_on_init_upload_error(
|
|
148
|
+
self, client: LinkedInClient, tmp_path: pathlib.Path
|
|
149
|
+
) -> None:
|
|
150
|
+
img = tmp_path / "photo.jpg"
|
|
151
|
+
img.write_bytes(b"\xff\xd8" + b"\x00" * 100)
|
|
152
|
+
|
|
153
|
+
with (
|
|
154
|
+
patch.object(
|
|
155
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
156
|
+
),
|
|
157
|
+
patch.object(
|
|
158
|
+
client._session, "post", return_value=_error_response(500)
|
|
159
|
+
),
|
|
160
|
+
):
|
|
161
|
+
with pytest.raises(Exception):
|
|
162
|
+
client.upload_image(img)
|
|
163
|
+
|
|
164
|
+
def test_raises_on_upload_error(
|
|
165
|
+
self, client: LinkedInClient, tmp_path: pathlib.Path
|
|
166
|
+
) -> None:
|
|
167
|
+
img = tmp_path / "photo.jpg"
|
|
168
|
+
img.write_bytes(b"\xff\xd8" + b"\x00" * 100)
|
|
169
|
+
|
|
170
|
+
init_resp = _ok_response({
|
|
171
|
+
"value": {
|
|
172
|
+
"uploadUrl": "https://linkedin.com/upload/xyz",
|
|
173
|
+
"image": "urn:li:image:C123",
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
put_resp = _error_response(500)
|
|
177
|
+
|
|
178
|
+
with (
|
|
179
|
+
patch.object(
|
|
180
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
181
|
+
),
|
|
182
|
+
patch.object(client._session, "post", return_value=init_resp),
|
|
183
|
+
patch.object(client._session, "put", return_value=put_resp),
|
|
184
|
+
):
|
|
185
|
+
with pytest.raises(Exception):
|
|
186
|
+
client.upload_image(img)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TestCreatePostWithImage:
|
|
190
|
+
def test_includes_image_content_in_body(self, client: LinkedInClient) -> None:
|
|
191
|
+
with (
|
|
192
|
+
patch.object(
|
|
193
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
194
|
+
),
|
|
195
|
+
patch.object(client._session, "post") as mock_post,
|
|
196
|
+
):
|
|
197
|
+
mock_post.return_value = _created_response("urn:li:share:999")
|
|
198
|
+
client.create_post("With image", image_urn="urn:li:image:C123")
|
|
199
|
+
|
|
200
|
+
body = mock_post.call_args.kwargs["json"]
|
|
201
|
+
assert body["content"] == {"media": {"id": "urn:li:image:C123"}}
|
|
202
|
+
|
|
203
|
+
def test_omits_content_when_no_image(self, client: LinkedInClient) -> None:
|
|
204
|
+
with (
|
|
205
|
+
patch.object(
|
|
206
|
+
client._session, "get", return_value=_ok_response({"sub": "u1"})
|
|
207
|
+
),
|
|
208
|
+
patch.object(client._session, "post") as mock_post,
|
|
209
|
+
):
|
|
210
|
+
mock_post.return_value = _created_response("urn:li:share:999")
|
|
211
|
+
client.create_post("Text only")
|
|
212
|
+
|
|
213
|
+
body = mock_post.call_args.kwargs["json"]
|
|
214
|
+
assert "content" not in body
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class TestCLIValidation:
|
|
218
|
+
def test_rejects_text_over_3000_chars(self) -> None:
|
|
219
|
+
config = DictConfigStore({"client_id": "id", "client_secret": "sec"})
|
|
220
|
+
long_text = "a" * 3001
|
|
221
|
+
with pytest.raises(SystemExit):
|
|
222
|
+
main([long_text], _config=config)
|
|
223
|
+
|
|
224
|
+
def test_rejects_empty_text(self) -> None:
|
|
225
|
+
config = DictConfigStore({"client_id": "id", "client_secret": "sec"})
|
|
226
|
+
with pytest.raises(SystemExit), patch("sys.stdin") as mock_stdin:
|
|
227
|
+
mock_stdin.read.return_value = ""
|
|
228
|
+
main([], _config=config)
|
|
229
|
+
|
|
230
|
+
def test_exits_when_credentials_missing(self) -> None:
|
|
231
|
+
config = DictConfigStore()
|
|
232
|
+
with pytest.raises(SystemExit), patch("builtins.input", return_value=""):
|
|
233
|
+
main(["Hello"], _config=config)
|
|
234
|
+
|
|
235
|
+
def test_shows_setup_guide_when_credentials_missing(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
236
|
+
config = DictConfigStore()
|
|
237
|
+
with pytest.raises(SystemExit), patch("builtins.input", return_value=""):
|
|
238
|
+
main(["Hello"], _config=config)
|
|
239
|
+
assert "First-time setup" in capsys.readouterr().out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TestCLIImage:
|
|
243
|
+
def test_passes_image_urn_to_create_post(self, tmp_path: pathlib.Path) -> None:
|
|
244
|
+
img = tmp_path / "pic.png"
|
|
245
|
+
img.write_bytes(b"\x89PNG" + b"\x00" * 100)
|
|
246
|
+
|
|
247
|
+
mock_client = MagicMock()
|
|
248
|
+
mock_client.upload_image.return_value = "urn:li:image:ABC"
|
|
249
|
+
mock_client.create_post.return_value = "urn:li:share:999"
|
|
250
|
+
|
|
251
|
+
config = DictConfigStore({
|
|
252
|
+
"client_id": "id", "client_secret": "sec", "access_token": "tok",
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
with (
|
|
256
|
+
patch("linkedin_post.cli.LinkedInClient", return_value=mock_client),
|
|
257
|
+
patch("linkedin_post.cli.is_token_valid", return_value=True),
|
|
258
|
+
):
|
|
259
|
+
main(["Hello", "--image", str(img)], _config=config)
|
|
260
|
+
|
|
261
|
+
mock_client.upload_image.assert_called_once_with(img)
|
|
262
|
+
mock_client.create_post.assert_called_once_with(
|
|
263
|
+
"Hello", connections_only=False, image_urn="urn:li:image:ABC",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def test_exits_when_image_not_found(self) -> None:
|
|
267
|
+
config = DictConfigStore({
|
|
268
|
+
"client_id": "id", "client_secret": "sec", "access_token": "tok",
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
with (
|
|
272
|
+
patch("linkedin_post.cli.is_token_valid", return_value=True),
|
|
273
|
+
pytest.raises(SystemExit),
|
|
274
|
+
):
|
|
275
|
+
main(["Hello", "--image", "/nonexistent/photo.png"], _config=config)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TestCLIResetFlags:
|
|
279
|
+
def test_reset_keys_clears_all_credentials(self) -> None:
|
|
280
|
+
config = DictConfigStore({
|
|
281
|
+
"client_id": "id", "client_secret": "sec", "access_token": "tok",
|
|
282
|
+
})
|
|
283
|
+
with pytest.raises(SystemExit), patch("builtins.input", return_value=""):
|
|
284
|
+
main(["Hello", "--reset-keys"], _config=config)
|
|
285
|
+
assert config.get("client_id") is None
|
|
286
|
+
assert config.get("client_secret") is None
|
|
287
|
+
assert config.get("access_token") is None
|
|
288
|
+
|
|
289
|
+
def test_reset_auth_clears_token_only(self) -> None:
|
|
290
|
+
config = DictConfigStore({
|
|
291
|
+
"client_id": "id", "client_secret": "sec", "access_token": "tok",
|
|
292
|
+
})
|
|
293
|
+
mock_client = MagicMock()
|
|
294
|
+
mock_client.create_post.return_value = "urn:li:share:1"
|
|
295
|
+
|
|
296
|
+
with (
|
|
297
|
+
patch("linkedin_post.cli.authenticate", return_value="new-tok"),
|
|
298
|
+
patch("linkedin_post.cli.LinkedInClient", return_value=mock_client),
|
|
299
|
+
):
|
|
300
|
+
main(["Hello", "--reset-auth"], _config=config)
|
|
301
|
+
|
|
302
|
+
assert config.get("client_id") == "id"
|
|
303
|
+
assert config.get("client_secret") == "sec"
|
|
304
|
+
assert config.get("access_token") == "new-tok"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- helpers ---
|
|
308
|
+
|
|
309
|
+
def _ok_response(json_data: dict) -> MagicMock:
|
|
310
|
+
resp = MagicMock()
|
|
311
|
+
resp.status_code = 200
|
|
312
|
+
resp.json.return_value = json_data
|
|
313
|
+
resp.raise_for_status.return_value = None
|
|
314
|
+
return resp
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _created_response(post_urn: str) -> MagicMock:
|
|
318
|
+
resp = MagicMock()
|
|
319
|
+
resp.status_code = 201
|
|
320
|
+
resp.headers = {"x-restli-id": post_urn}
|
|
321
|
+
resp.raise_for_status.return_value = None
|
|
322
|
+
return resp
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _error_response(status_code: int) -> MagicMock:
|
|
326
|
+
from requests.exceptions import HTTPError
|
|
327
|
+
|
|
328
|
+
resp = MagicMock()
|
|
329
|
+
resp.status_code = status_code
|
|
330
|
+
resp.ok = False
|
|
331
|
+
resp.text = "error"
|
|
332
|
+
resp.raise_for_status.side_effect = HTTPError(response=resp)
|
|
333
|
+
return resp
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Tests for JsonConfigStore and prompt_if_missing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from linkedin_post.config import JsonConfigStore, prompt_if_missing
|
|
10
|
+
|
|
11
|
+
from helpers import DictConfigStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --- JsonConfigStore ---
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestJsonConfigStore:
|
|
18
|
+
def test_get_returns_none_when_file_missing(self, tmp_path: pathlib.Path) -> None:
|
|
19
|
+
store = JsonConfigStore(tmp_path / "missing" / "config.json")
|
|
20
|
+
assert store.get("anything") is None
|
|
21
|
+
|
|
22
|
+
def test_set_creates_file_and_dirs(self, tmp_path: pathlib.Path) -> None:
|
|
23
|
+
path = tmp_path / "sub" / "config.json"
|
|
24
|
+
store = JsonConfigStore(path)
|
|
25
|
+
store.set("key", "val")
|
|
26
|
+
assert path.exists()
|
|
27
|
+
assert json.loads(path.read_text()) == {"key": "val"}
|
|
28
|
+
|
|
29
|
+
def test_set_preserves_existing_keys(self, tmp_path: pathlib.Path) -> None:
|
|
30
|
+
path = tmp_path / "config.json"
|
|
31
|
+
store = JsonConfigStore(path)
|
|
32
|
+
store.set("a", "1")
|
|
33
|
+
store.set("b", "2")
|
|
34
|
+
assert json.loads(path.read_text()) == {"a": "1", "b": "2"}
|
|
35
|
+
|
|
36
|
+
def test_set_many_writes_multiple_keys(self, tmp_path: pathlib.Path) -> None:
|
|
37
|
+
path = tmp_path / "config.json"
|
|
38
|
+
store = JsonConfigStore(path)
|
|
39
|
+
store.set_many({"x": "10", "y": "20"})
|
|
40
|
+
assert json.loads(path.read_text()) == {"x": "10", "y": "20"}
|
|
41
|
+
|
|
42
|
+
def test_remove_deletes_keys(self, tmp_path: pathlib.Path) -> None:
|
|
43
|
+
path = tmp_path / "config.json"
|
|
44
|
+
store = JsonConfigStore(path)
|
|
45
|
+
store.set_many({"a": "1", "b": "2", "c": "3"})
|
|
46
|
+
store.remove(["a", "c"])
|
|
47
|
+
assert json.loads(path.read_text()) == {"b": "2"}
|
|
48
|
+
|
|
49
|
+
def test_remove_ignores_missing_keys(self, tmp_path: pathlib.Path) -> None:
|
|
50
|
+
path = tmp_path / "config.json"
|
|
51
|
+
store = JsonConfigStore(path)
|
|
52
|
+
store.set("a", "1")
|
|
53
|
+
store.remove(["nonexistent"])
|
|
54
|
+
assert json.loads(path.read_text()) == {"a": "1"}
|
|
55
|
+
|
|
56
|
+
def test_file_permissions(self, tmp_path: pathlib.Path) -> None:
|
|
57
|
+
path = tmp_path / "config.json"
|
|
58
|
+
store = JsonConfigStore(path)
|
|
59
|
+
store.set("secret", "value")
|
|
60
|
+
mode = os.stat(path).st_mode & 0o777
|
|
61
|
+
assert mode == 0o600
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- prompt_if_missing ---
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestPromptIfMissing:
|
|
68
|
+
def test_returns_existing_value_without_prompting(self) -> None:
|
|
69
|
+
config = DictConfigStore({"key": "existing"})
|
|
70
|
+
called = False
|
|
71
|
+
|
|
72
|
+
def fake_prompt(_msg: str) -> str:
|
|
73
|
+
nonlocal called
|
|
74
|
+
called = True
|
|
75
|
+
return "new"
|
|
76
|
+
|
|
77
|
+
result = prompt_if_missing(config, "key", "Key", prompt_fn=fake_prompt)
|
|
78
|
+
assert result == "existing"
|
|
79
|
+
assert not called
|
|
80
|
+
|
|
81
|
+
def test_prompts_and_saves_when_missing(self) -> None:
|
|
82
|
+
config = DictConfigStore()
|
|
83
|
+
result = prompt_if_missing(
|
|
84
|
+
config, "key", "Key", prompt_fn=lambda _: "user-input",
|
|
85
|
+
)
|
|
86
|
+
assert result == "user-input"
|
|
87
|
+
assert config.get("key") == "user-input"
|
|
88
|
+
|
|
89
|
+
def test_exits_on_empty_input(self) -> None:
|
|
90
|
+
config = DictConfigStore()
|
|
91
|
+
with pytest.raises(SystemExit):
|
|
92
|
+
prompt_if_missing(config, "key", "Key", prompt_fn=lambda _: "")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "certifi"
|
|
7
|
+
version = "2026.1.4"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "charset-normalizer"
|
|
16
|
+
version = "3.4.4"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
|
|
21
|
+
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
|
|
22
|
+
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
|
|
23
|
+
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
|
|
24
|
+
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
|
|
25
|
+
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
|
|
26
|
+
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
|
|
27
|
+
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
|
|
28
|
+
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
|
|
29
|
+
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
|
|
30
|
+
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
|
|
31
|
+
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
|
|
32
|
+
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
|
|
33
|
+
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
|
|
34
|
+
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
|
|
35
|
+
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
|
37
|
+
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
|
38
|
+
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
|
39
|
+
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
|
40
|
+
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
|
42
|
+
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
|
43
|
+
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
|
44
|
+
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
|
45
|
+
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
|
46
|
+
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
|
47
|
+
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
|
49
|
+
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
|
51
|
+
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
|
52
|
+
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
|
53
|
+
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
|
55
|
+
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
|
56
|
+
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
|
57
|
+
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
|
58
|
+
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
|
59
|
+
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
|
60
|
+
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
|
61
|
+
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
|
62
|
+
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
|
63
|
+
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
|
64
|
+
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
|
65
|
+
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
|
67
|
+
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
|
68
|
+
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
|
69
|
+
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
|
70
|
+
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
|
71
|
+
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
|
72
|
+
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
|
73
|
+
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
|
74
|
+
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
|
75
|
+
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
|
76
|
+
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
|
77
|
+
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
|
79
|
+
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
|
80
|
+
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
|
81
|
+
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
|
83
|
+
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
|
84
|
+
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[[package]]
|
|
88
|
+
name = "colorama"
|
|
89
|
+
version = "0.4.6"
|
|
90
|
+
source = { registry = "https://pypi.org/simple" }
|
|
91
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
92
|
+
wheels = [
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[[package]]
|
|
97
|
+
name = "idna"
|
|
98
|
+
version = "3.11"
|
|
99
|
+
source = { registry = "https://pypi.org/simple" }
|
|
100
|
+
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
|
101
|
+
wheels = [
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
[[package]]
|
|
106
|
+
name = "iniconfig"
|
|
107
|
+
version = "2.3.0"
|
|
108
|
+
source = { registry = "https://pypi.org/simple" }
|
|
109
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
110
|
+
wheels = [
|
|
111
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[[package]]
|
|
115
|
+
name = "linkedin-post-cli"
|
|
116
|
+
version = "0.1.0"
|
|
117
|
+
source = { editable = "." }
|
|
118
|
+
dependencies = [
|
|
119
|
+
{ name = "requests" },
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
[package.dev-dependencies]
|
|
123
|
+
dev = [
|
|
124
|
+
{ name = "pytest" },
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[package.metadata]
|
|
128
|
+
requires-dist = [{ name = "requests" }]
|
|
129
|
+
|
|
130
|
+
[package.metadata.requires-dev]
|
|
131
|
+
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
|
|
132
|
+
|
|
133
|
+
[[package]]
|
|
134
|
+
name = "packaging"
|
|
135
|
+
version = "26.0"
|
|
136
|
+
source = { registry = "https://pypi.org/simple" }
|
|
137
|
+
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
|
138
|
+
wheels = [
|
|
139
|
+
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
[[package]]
|
|
143
|
+
name = "pluggy"
|
|
144
|
+
version = "1.6.0"
|
|
145
|
+
source = { registry = "https://pypi.org/simple" }
|
|
146
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
147
|
+
wheels = [
|
|
148
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
[[package]]
|
|
152
|
+
name = "pygments"
|
|
153
|
+
version = "2.19.2"
|
|
154
|
+
source = { registry = "https://pypi.org/simple" }
|
|
155
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
|
156
|
+
wheels = [
|
|
157
|
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
[[package]]
|
|
161
|
+
name = "pytest"
|
|
162
|
+
version = "9.0.2"
|
|
163
|
+
source = { registry = "https://pypi.org/simple" }
|
|
164
|
+
dependencies = [
|
|
165
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
166
|
+
{ name = "iniconfig" },
|
|
167
|
+
{ name = "packaging" },
|
|
168
|
+
{ name = "pluggy" },
|
|
169
|
+
{ name = "pygments" },
|
|
170
|
+
]
|
|
171
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
172
|
+
wheels = [
|
|
173
|
+
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
[[package]]
|
|
177
|
+
name = "requests"
|
|
178
|
+
version = "2.32.5"
|
|
179
|
+
source = { registry = "https://pypi.org/simple" }
|
|
180
|
+
dependencies = [
|
|
181
|
+
{ name = "certifi" },
|
|
182
|
+
{ name = "charset-normalizer" },
|
|
183
|
+
{ name = "idna" },
|
|
184
|
+
{ name = "urllib3" },
|
|
185
|
+
]
|
|
186
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
|
187
|
+
wheels = [
|
|
188
|
+
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
[[package]]
|
|
192
|
+
name = "urllib3"
|
|
193
|
+
version = "2.6.3"
|
|
194
|
+
source = { registry = "https://pypi.org/simple" }
|
|
195
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
|
196
|
+
wheels = [
|
|
197
|
+
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
|
198
|
+
]
|