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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run pytest:*)",
5
+ "WebSearch"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,7 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkedin-post-cli
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: requests
@@ -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
+ ]