x-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,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run pytest:*)",
5
+ "Bash(git -C /Users/andrei/Developer/ai-first/x-post-cli add src/x/client.py src/x/cli.py tests/test_client.py)",
6
+ "Bash(git -C:*)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,7 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: x-post-cli
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: requests
6
+ Requires-Dist: requests-oauthlib
@@ -0,0 +1,54 @@
1
+ # x-post-cli
2
+
3
+ CLI utility for publishing tweets to Twitter/X via the official API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Run directly (no install needed)
9
+ uvx x-post-cli "Hello world!"
10
+
11
+ # Or install globally
12
+ uv tool install x-post-cli
13
+ ```
14
+
15
+ ## Prerequisites
16
+
17
+ 1. Create a project and app at https://developer.x.com
18
+ 2. In **User authentication settings** → Enable OAuth 2.0
19
+ 3. Type of App: **Native App** (public client, PKCE)
20
+ 4. Callback URL: `http://localhost:8000/callback`
21
+ 5. Copy `Client ID` and `Client Secret`
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # First run — you'll be prompted for Client ID and Client Secret
27
+ x-post-cli "My first tweet via API!"
28
+
29
+ # From file
30
+ x-post-cli --from-file draft.txt
31
+
32
+ # Interactive input (Ctrl+D to send)
33
+ x-post-cli
34
+
35
+ # With an image (prompts for OAuth 1.0a keys on first use)
36
+ x-post-cli --image photo.jpg "Check this out!"
37
+
38
+ # Reply to a tweet (threading)
39
+ x-post-cli --reply-to 123456789 "Replying!"
40
+
41
+ # Re-authorize (clears OAuth 2.0 tokens)
42
+ x-post-cli --reset-auth "Hello again"
43
+
44
+ # Clear all credentials and start fresh
45
+ x-post-cli --reset-keys "Starting over"
46
+ ```
47
+
48
+ Credentials are requested interactively on first run and saved to `~/.config/x-post/config.json`. On subsequent runs no prompts are needed. The config file can also be edited manually.
49
+
50
+ ## Tests
51
+
52
+ ```bash
53
+ uv run pytest
54
+ ```
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "x-post-cli"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.11"
5
+ dependencies = ["requests", "requests-oauthlib"]
6
+
7
+ [project.scripts]
8
+ x-post-cli = "x_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/x_post"]
File without changes
@@ -0,0 +1,145 @@
1
+ """OAuth 2.0 PKCE authentication flow for X."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import http.server
6
+ import secrets
7
+ import sys
8
+ import threading
9
+ import urllib.parse
10
+ import webbrowser
11
+
12
+ import requests
13
+
14
+ _AUTH_URL = "https://twitter.com/i/oauth2/authorize"
15
+ _TOKEN_URL = "https://api.x.com/2/oauth2/token"
16
+ _USERINFO_URL = "https://api.x.com/2/users/me"
17
+ _REDIRECT_URI = "http://localhost:8000/callback"
18
+ _SCOPES = "tweet.write tweet.read users.read offline.access"
19
+
20
+
21
+ def _generate_pkce() -> tuple[str, str]:
22
+ """Generate PKCE code_verifier and code_challenge (S256)."""
23
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
24
+ digest = hashlib.sha256(verifier.encode()).digest()
25
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
26
+ return verifier, challenge
27
+
28
+
29
+ def is_token_valid(token: str) -> bool:
30
+ """Check whether *token* is still accepted by X API."""
31
+ resp = requests.get(
32
+ _USERINFO_URL,
33
+ headers={"Authorization": f"Bearer {token}"},
34
+ timeout=10,
35
+ )
36
+ return resp.status_code == 200
37
+
38
+
39
+ def refresh_access_token(
40
+ client_id: str, client_secret: str, refresh_token: str,
41
+ ) -> tuple[str, str]:
42
+ """Exchange a refresh token for new access + refresh tokens."""
43
+ resp = requests.post(
44
+ _TOKEN_URL,
45
+ auth=(client_id, client_secret),
46
+ data={
47
+ "grant_type": "refresh_token",
48
+ "refresh_token": refresh_token,
49
+ },
50
+ timeout=30,
51
+ )
52
+ resp.raise_for_status()
53
+ data = resp.json()
54
+ return data["access_token"], data["refresh_token"]
55
+
56
+
57
+ def authenticate(client_id: str, client_secret: str) -> tuple[str, str]:
58
+ """Run the full OAuth 2.0 PKCE browser flow.
59
+
60
+ Opens the default browser for user consent, captures the redirect
61
+ on a local server, and exchanges the code for tokens.
62
+
63
+ Returns (access_token, refresh_token).
64
+ """
65
+ code_verifier, code_challenge = _generate_pkce()
66
+ state = secrets.token_urlsafe(16)
67
+
68
+ auth_code: str | None = None
69
+ error: str | None = None
70
+ server_ready = threading.Event()
71
+
72
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
73
+ def do_GET(self) -> None: # noqa: N802
74
+ nonlocal auth_code, error
75
+ params = urllib.parse.parse_qs(
76
+ urllib.parse.urlparse(self.path).query,
77
+ )
78
+
79
+ received_state = params.get("state", [None])[0]
80
+ if received_state != state:
81
+ error = "state_mismatch"
82
+ self._respond("Authorization failed: state mismatch.")
83
+ elif "code" in params:
84
+ auth_code = params["code"][0]
85
+ self._respond("Authorization successful! You can close this tab.")
86
+ else:
87
+ error = params.get("error", ["unknown"])[0]
88
+ self._respond(f"Authorization failed: {error}")
89
+
90
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
91
+
92
+ def _respond(self, message: str) -> None:
93
+ self.send_response(200)
94
+ self.send_header("Content-Type", "text/html; charset=utf-8")
95
+ self.end_headers()
96
+ self.wfile.write(
97
+ f"<html><body><h2>{message}</h2></body></html>".encode(),
98
+ )
99
+
100
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
101
+ pass
102
+
103
+ server = http.server.HTTPServer(("localhost", 8000), _CallbackHandler)
104
+
105
+ def _serve() -> None:
106
+ server_ready.set()
107
+ server.serve_forever()
108
+
109
+ thread = threading.Thread(target=_serve, daemon=True)
110
+ thread.start()
111
+ server_ready.wait()
112
+
113
+ params = urllib.parse.urlencode({
114
+ "response_type": "code",
115
+ "client_id": client_id,
116
+ "redirect_uri": _REDIRECT_URI,
117
+ "scope": _SCOPES,
118
+ "state": state,
119
+ "code_challenge": code_challenge,
120
+ "code_challenge_method": "S256",
121
+ })
122
+ authorization_url = f"{_AUTH_URL}?{params}"
123
+ print(f"Opening browser for authorization...\n{authorization_url}")
124
+ webbrowser.open(authorization_url)
125
+
126
+ thread.join()
127
+
128
+ if error or auth_code is None:
129
+ print(f"Authorization failed: {error}", file=sys.stderr)
130
+ sys.exit(1)
131
+
132
+ token_resp = requests.post(
133
+ _TOKEN_URL,
134
+ auth=(client_id, client_secret),
135
+ data={
136
+ "grant_type": "authorization_code",
137
+ "code": auth_code,
138
+ "redirect_uri": _REDIRECT_URI,
139
+ "code_verifier": code_verifier,
140
+ },
141
+ timeout=30,
142
+ )
143
+ token_resp.raise_for_status()
144
+ data = token_resp.json()
145
+ return data["access_token"], data["refresh_token"]
@@ -0,0 +1,182 @@
1
+ """CLI entry-point for x-post."""
2
+
3
+ import argparse
4
+ import pathlib
5
+ import sys
6
+
7
+ from x_post.auth import authenticate, is_token_valid, refresh_access_token
8
+ from x_post.client import OAuth1Credentials, XClient
9
+ from x_post.config import ConfigStore, JsonConfigStore, prompt_if_missing
10
+
11
+ _MAX_LENGTH = 280
12
+
13
+
14
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
15
+ parser = argparse.ArgumentParser(
16
+ description="Publish a tweet on Twitter/X.",
17
+ )
18
+ parser.add_argument("text", nargs="?", help="Tweet text (inline)")
19
+ parser.add_argument(
20
+ "--from-file", type=pathlib.Path, help="Read tweet text from a file",
21
+ )
22
+ parser.add_argument(
23
+ "--reply-to",
24
+ type=str,
25
+ metavar="TWEET_ID",
26
+ help="Tweet ID to reply to (for threading)",
27
+ )
28
+ parser.add_argument(
29
+ "--image",
30
+ type=pathlib.Path,
31
+ metavar="PATH",
32
+ help="Attach an image (jpg/png/gif/webp, max 5 MB)",
33
+ )
34
+ parser.add_argument(
35
+ "--reset-auth",
36
+ action="store_true",
37
+ help="Clear saved OAuth 2.0 tokens and re-authorize",
38
+ )
39
+ parser.add_argument(
40
+ "--reset-keys",
41
+ action="store_true",
42
+ help="Clear all saved credentials and re-prompt from scratch",
43
+ )
44
+ return parser.parse_args(argv)
45
+
46
+
47
+ def _read_post_text(args: argparse.Namespace) -> str:
48
+ if args.text:
49
+ return args.text
50
+ if args.from_file:
51
+ return args.from_file.read_text(encoding="utf-8").strip()
52
+ print("Enter tweet text (Ctrl+D to send):")
53
+ return sys.stdin.read().strip()
54
+
55
+
56
+ def _ensure_token(
57
+ config: ConfigStore,
58
+ client_id: str,
59
+ client_secret: str,
60
+ *,
61
+ force: bool,
62
+ ) -> str:
63
+ """Return a valid access token, running OAuth if needed."""
64
+ access_token = config.get("access_token")
65
+ refresh_token = config.get("refresh_token")
66
+
67
+ if not force and access_token and is_token_valid(access_token):
68
+ return access_token
69
+
70
+ if not force and refresh_token:
71
+ try:
72
+ new_access, new_refresh = refresh_access_token(
73
+ client_id, client_secret, refresh_token,
74
+ )
75
+ config.set_many({
76
+ "access_token": new_access,
77
+ "refresh_token": new_refresh,
78
+ })
79
+ return new_access
80
+ except Exception:
81
+ pass # fall through to full auth
82
+
83
+ new_access, new_refresh = authenticate(client_id, client_secret)
84
+ config.set_many({"access_token": new_access, "refresh_token": new_refresh})
85
+ return new_access
86
+
87
+
88
+ def _ensure_oauth1(config: ConfigStore) -> OAuth1Credentials:
89
+ """Return OAuth 1.0a credentials, prompting for any missing keys."""
90
+ if not config.get("api_key"):
91
+ print(
92
+ "\n"
93
+ "Image upload requires OAuth 1.0a credentials\n"
94
+ "=============================================\n"
95
+ "In your app at https://developer.x.com:\n"
96
+ "\n"
97
+ "1. Go to Keys and Tokens tab\n"
98
+ "2. Under Consumer Keys, copy API Key and API Key Secret\n"
99
+ "3. Under Authentication Tokens, generate Access Token and Secret\n"
100
+ " (make sure the token has Read and Write permissions)\n",
101
+ )
102
+
103
+ return OAuth1Credentials(
104
+ api_key=prompt_if_missing(config, "api_key", "API Key"),
105
+ api_key_secret=prompt_if_missing(config, "api_key_secret", "API Key Secret"),
106
+ access_token=prompt_if_missing(config, "oauth1_access_token", "OAuth 1.0a Access Token"),
107
+ access_token_secret=prompt_if_missing(
108
+ config, "oauth1_access_token_secret", "OAuth 1.0a Access Token Secret",
109
+ ),
110
+ )
111
+
112
+
113
+ def main(argv: list[str] | None = None, *, _config: ConfigStore | None = None) -> None:
114
+ args = _parse_args(argv)
115
+ config = _config or JsonConfigStore()
116
+
117
+ if args.reset_keys:
118
+ config.remove([
119
+ "client_id", "client_secret",
120
+ "access_token", "refresh_token",
121
+ "api_key", "api_key_secret",
122
+ "oauth1_access_token", "oauth1_access_token_secret",
123
+ ])
124
+ elif args.reset_auth:
125
+ config.remove(["access_token", "refresh_token"])
126
+
127
+ if not config.get("client_id"):
128
+ print(
129
+ "\n"
130
+ "First-time setup\n"
131
+ "================\n"
132
+ "You need OAuth 2.0 credentials from the X Developer Portal.\n"
133
+ "\n"
134
+ "1. Go to https://developer.x.com and create a project & app\n"
135
+ "2. In User authentication settings, enable OAuth 2.0\n"
136
+ "3. Set type to Native App, callback URL: http://localhost:8000/callback\n"
137
+ "4. Copy the Client ID and Client Secret below\n",
138
+ )
139
+
140
+ client_id = prompt_if_missing(config, "client_id", "Client ID")
141
+ client_secret = prompt_if_missing(config, "client_secret", "Client Secret")
142
+
143
+ text = _read_post_text(args)
144
+ if not text:
145
+ print("Empty tweet text, aborting.", file=sys.stderr)
146
+ sys.exit(1)
147
+ if len(text) > _MAX_LENGTH:
148
+ print(
149
+ f"Tweet too long: {len(text)}/{_MAX_LENGTH} characters.",
150
+ file=sys.stderr,
151
+ )
152
+ sys.exit(1)
153
+
154
+ access_token = _ensure_token(
155
+ config, client_id, client_secret,
156
+ force=args.reset_auth,
157
+ )
158
+
159
+ oauth1: OAuth1Credentials | None = None
160
+ if args.image:
161
+ oauth1 = _ensure_oauth1(config)
162
+
163
+ client = XClient(access_token, oauth1=oauth1)
164
+
165
+ media_ids: list[str] | None = None
166
+ if args.image:
167
+ if not args.image.exists():
168
+ print(f"Image not found: {args.image}", file=sys.stderr)
169
+ sys.exit(1)
170
+ try:
171
+ media_id = client.upload_media(args.image)
172
+ except ValueError as exc:
173
+ print(str(exc), file=sys.stderr)
174
+ sys.exit(1)
175
+ media_ids = [media_id]
176
+
177
+ result = client.create_tweet(
178
+ text, reply_to_tweet_id=args.reply_to, media_ids=media_ids,
179
+ )
180
+ print(f"Tweet published!\n{result.url}")
181
+ print(f"\nTo continue this thread:\n"
182
+ f"x-post-cli --reply-to {result.tweet_id} \"Next tweet text\"")
@@ -0,0 +1,146 @@
1
+ """X API client for creating posts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+ import requests
10
+ from requests_oauthlib import OAuth1
11
+
12
+ _BASE_URL = "https://api.x.com/2"
13
+ _UPLOAD_URL = "https://upload.twitter.com/1.1/media/upload.json"
14
+ _SUPPORTED_IMAGE_TYPES = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
15
+ _MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class OAuth1Credentials:
20
+ """OAuth 1.0a credentials required for media uploads."""
21
+
22
+ api_key: str
23
+ api_key_secret: str
24
+ access_token: str
25
+ access_token_secret: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TweetResult:
30
+ """Result of publishing a tweet."""
31
+
32
+ tweet_id: str
33
+ url: str
34
+
35
+
36
+ class XAPI(Protocol):
37
+ """Interface for X API operations."""
38
+
39
+ def get_username(self) -> str:
40
+ """Return the authenticated user's username."""
41
+ ...
42
+
43
+ def create_tweet(
44
+ self,
45
+ text: str,
46
+ *,
47
+ reply_to_tweet_id: str | None = None,
48
+ media_ids: list[str] | None = None,
49
+ ) -> TweetResult:
50
+ """Publish a tweet and return the result with ID and URL."""
51
+ ...
52
+
53
+
54
+ class XClient:
55
+ """HTTP client for X REST API v2.
56
+
57
+ Usage::
58
+
59
+ client = XClient(access_token="...")
60
+ url = client.create_tweet("Hello X!")
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ access_token: str,
66
+ *,
67
+ oauth1: OAuth1Credentials | None = None,
68
+ ) -> None:
69
+ self._session = requests.Session()
70
+ self._session.headers.update({
71
+ "Authorization": f"Bearer {access_token}",
72
+ })
73
+ self._oauth1 = oauth1
74
+ self._username: str | None = None
75
+
76
+ def get_username(self) -> str:
77
+ """Return the authenticated user's @username (cached)."""
78
+ if self._username is None:
79
+ resp = self._session.get(f"{_BASE_URL}/users/me")
80
+ resp.raise_for_status()
81
+ self._username = resp.json()["data"]["username"]
82
+ return self._username
83
+
84
+ def upload_media(self, path: pathlib.Path) -> str:
85
+ """Upload an image file and return the media_id string.
86
+
87
+ Requires OAuth 1.0a credentials (passed via ``oauth1`` at construction).
88
+ Supports jpg, png, gif, webp up to 5 MB.
89
+ Raises ``ValueError`` for unsupported format, oversized files,
90
+ or missing OAuth 1.0a credentials.
91
+ """
92
+ if self._oauth1 is None:
93
+ raise ValueError(
94
+ "OAuth 1.0a credentials are required for media uploads.",
95
+ )
96
+ suffix = path.suffix.lower()
97
+ if suffix not in _SUPPORTED_IMAGE_TYPES:
98
+ raise ValueError(
99
+ f"Unsupported image format '{suffix}'. "
100
+ f"Supported: {', '.join(sorted(_SUPPORTED_IMAGE_TYPES))}",
101
+ )
102
+ size = path.stat().st_size
103
+ if size > _MAX_IMAGE_SIZE:
104
+ raise ValueError(
105
+ f"Image too large ({size / 1024 / 1024:.1f} MB). "
106
+ f"Maximum: {_MAX_IMAGE_SIZE / 1024 / 1024:.0f} MB",
107
+ )
108
+ auth = OAuth1(
109
+ self._oauth1.api_key,
110
+ self._oauth1.api_key_secret,
111
+ self._oauth1.access_token,
112
+ self._oauth1.access_token_secret,
113
+ )
114
+ with open(path, "rb") as f:
115
+ resp = requests.post(_UPLOAD_URL, files={"media": f}, auth=auth)
116
+ if not resp.ok:
117
+ raise requests.HTTPError(
118
+ f"{resp.status_code}: {resp.text}", response=resp,
119
+ )
120
+ return str(resp.json()["media_id"])
121
+
122
+ def create_tweet(
123
+ self,
124
+ text: str,
125
+ *,
126
+ reply_to_tweet_id: str | None = None,
127
+ media_ids: list[str] | None = None,
128
+ ) -> TweetResult:
129
+ """Publish a tweet, optionally with media or as a reply."""
130
+ body: dict = {"text": text}
131
+ if reply_to_tweet_id is not None:
132
+ body["reply"] = {"in_reply_to_tweet_id": reply_to_tweet_id}
133
+ if media_ids is not None:
134
+ body["media"] = {"media_ids": media_ids}
135
+
136
+ resp = self._session.post(f"{_BASE_URL}/tweets", json=body)
137
+ if not resp.ok:
138
+ raise requests.HTTPError(
139
+ f"{resp.status_code}: {resp.text}", response=resp,
140
+ )
141
+ tweet_id = resp.json()["data"]["id"]
142
+ username = self.get_username()
143
+ return TweetResult(
144
+ tweet_id=tweet_id,
145
+ url=f"https://x.com/{username}/status/{tweet_id}",
146
+ )
@@ -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" / "x-post" / "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/x-post/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 @@
1
+ """Shared test fixtures."""