librepost-app 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
librepost/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """A free, local-first API client."""
2
+
3
+ __version__ = "0.1.0"
librepost/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app(prog_name="librepost")
librepost/cli.py ADDED
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import getpass
5
+ import secrets
6
+ import socket
7
+ import sys
8
+ import webbrowser
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import httpx
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from . import __version__, environments, leakcheck, runner, secretstore
17
+ from .environments import EnvFileError
18
+ from .httpfile import HttpFileError, parse
19
+ from .secretstore import SecretError
20
+ from .template import TemplateError
21
+
22
+ app = typer.Typer(
23
+ help="A free, local-first API client. Requests are .http files; secrets stay in your keychain.",
24
+ no_args_is_help=True,
25
+ add_completion=False,
26
+ )
27
+ secret_app = typer.Typer(help="Store secrets in the OS keychain for {{secret:keychain/<name>}}.")
28
+ app.add_typer(secret_app, name="secret")
29
+
30
+ out = Console(highlight=False)
31
+ err = Console(stderr=True, highlight=False)
32
+
33
+ DEFAULT_PORT = 8765
34
+
35
+
36
+ def _version(value: bool) -> None:
37
+ if value:
38
+ out.print(f"librepost {__version__}")
39
+ raise typer.Exit()
40
+
41
+
42
+ @app.callback()
43
+ def main(
44
+ version: Annotated[
45
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
46
+ ] = False,
47
+ ) -> None:
48
+ pass
49
+
50
+
51
+ def _bind(port: int) -> socket.socket:
52
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
53
+ if sys.platform != "win32":
54
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
55
+ try:
56
+ sock.bind(("127.0.0.1", port))
57
+ except OSError:
58
+ if port == 0:
59
+ raise
60
+ sock.bind(("127.0.0.1", 0))
61
+ return sock
62
+
63
+
64
+ @app.command()
65
+ def ui(
66
+ directory: Annotated[
67
+ Path, typer.Argument(help="Folder holding your .http files.", file_okay=False)
68
+ ] = Path("."),
69
+ port: Annotated[
70
+ int, typer.Option(help="Port on 127.0.0.1 (a free one is picked if it's taken).")
71
+ ] = DEFAULT_PORT,
72
+ open_browser: Annotated[
73
+ bool, typer.Option("--open/--no-open", help="Open the UI in your browser.")
74
+ ] = True,
75
+ ) -> None:
76
+ """Start the UI on 127.0.0.1 and open it in your browser."""
77
+ import uvicorn
78
+
79
+ from .server import create_app
80
+
81
+ if not directory.is_dir():
82
+ err.print(f"[red]Not a folder:[/] {directory}")
83
+ raise typer.Exit(2)
84
+ sock = _bind(port)
85
+ port = sock.getsockname()[1]
86
+ token = secrets.token_urlsafe(32)
87
+ url = f"http://127.0.0.1:{port}/?token={token}"
88
+ application = create_app(directory, port=port, token=token)
89
+
90
+ out.print(f"[bold green]librepost {__version__}[/] serving [bold]{directory.resolve()}[/]")
91
+ out.print(f"Open: [link={url}]{url}[/link]")
92
+ out.print(
93
+ "[dim]Only this machine can connect, and only with the link above. Ctrl+C stops it.[/]"
94
+ )
95
+ if open_browser:
96
+ webbrowser.open(url)
97
+ config = uvicorn.Config(application, log_level="warning", access_log=False, server_header=False)
98
+ with contextlib.suppress(KeyboardInterrupt):
99
+ uvicorn.Server(config).run(sockets=[sock])
100
+
101
+
102
+ @app.command()
103
+ def run(
104
+ file: Annotated[Path, typer.Argument(help="An .http file.", exists=True, dir_okay=False)],
105
+ name: Annotated[
106
+ str | None, typer.Option("--name", "-n", help="Request name (default: the first one).")
107
+ ] = None,
108
+ env: Annotated[str | None, typer.Option("--env", "-e", help="Environment to use.")] = None,
109
+ verbose: Annotated[
110
+ bool, typer.Option("--verbose", "-v", help="Show the request and response headers.")
111
+ ] = False,
112
+ fail: Annotated[
113
+ bool, typer.Option("--fail", "-f", help="Exit with status 22 on HTTP 4xx/5xx.")
114
+ ] = False,
115
+ insecure: Annotated[
116
+ bool, typer.Option("--insecure", "-k", help="Don't verify TLS certificates.")
117
+ ] = False,
118
+ timeout: Annotated[float, typer.Option(help="Timeout in seconds.")] = 30.0,
119
+ ) -> None:
120
+ """Send a request from an .http file and print the response."""
121
+ try:
122
+ httpfile = parse(file.read_text(encoding="utf-8"))
123
+ except (OSError, UnicodeDecodeError) as exc:
124
+ err.print(f"[red]Can't read {file}:[/] {exc}")
125
+ raise typer.Exit(2) from None
126
+ if not httpfile.requests:
127
+ err.print(f"[red]No requests in {file}.[/]")
128
+ raise typer.Exit(2)
129
+ if name is None:
130
+ request = httpfile.requests[0]
131
+ else:
132
+ matches = [r for r in httpfile.requests if r.name == name]
133
+ if not matches:
134
+ names = ", ".join(r.name for r in httpfile.requests if r.name) or "(none)"
135
+ err.print(f"[red]No request named {name!r}.[/] Names in this file: {names}")
136
+ raise typer.Exit(2)
137
+ request = matches[0]
138
+
139
+ root = _workspace_root(file)
140
+ try:
141
+ variables = dict(httpfile.variables)
142
+ variables.update(environments.variables(root, env))
143
+ prepared = runner.prepare(request, variables)
144
+ except (EnvFileError, TemplateError, SecretError, HttpFileError) as exc:
145
+ err.print(f"[red]{exc}[/]")
146
+ raise typer.Exit(2) from None
147
+
148
+ if verbose:
149
+ for line in prepared.masked().split("\n\n", 1)[0].splitlines():
150
+ err.print(f"[dim]> {line}[/]")
151
+ err.print()
152
+ try:
153
+ result = runner.send(prepared, timeout=timeout, verify=not insecure)
154
+ except httpx.HTTPError as exc:
155
+ err.print(f"[red]{type(exc).__name__}:[/] {prepared.renderer.mask(str(exc))}")
156
+ raise typer.Exit(1) from None
157
+
158
+ colour = "green" if result.status < 400 else "red"
159
+ err.print(
160
+ f"[bold {colour}]{result.status} {result.reason}[/] "
161
+ f"[dim]{result.elapsed_ms:.0f} ms, {len(result.body)} bytes[/]"
162
+ )
163
+ if verbose:
164
+ for key, value in result.headers:
165
+ err.print(f"[dim]< {key}: {value}[/]")
166
+ err.print()
167
+ sys.stdout.buffer.write(result.body)
168
+ if result.body and not result.body.endswith(b"\n") and sys.stdout.isatty():
169
+ sys.stdout.write("\n")
170
+ sys.stdout.flush()
171
+ if fail and result.status >= 400:
172
+ raise typer.Exit(22)
173
+
174
+
175
+ def _workspace_root(file: Path) -> Path:
176
+ """The nearest folder above the file that has an environment file, else the file's folder."""
177
+ start = file.resolve().parent
178
+ for folder in (start, *start.parents):
179
+ if (folder / environments.PUBLIC_FILE).is_file() or (
180
+ folder / environments.PRIVATE_FILE
181
+ ).is_file():
182
+ return folder
183
+ if (folder / ".git").exists():
184
+ break
185
+ return start
186
+
187
+
188
+ @app.command()
189
+ def check(
190
+ paths: Annotated[
191
+ list[Path] | None, typer.Argument(help="Files or folders (default: current folder).")
192
+ ] = None,
193
+ ) -> None:
194
+ """Find literal secrets in .http and environment files. Exits 1 if any are found."""
195
+ targets = paths or [Path(".")]
196
+ leaks = leakcheck.scan(targets, display_root=Path.cwd())
197
+ for leak in leaks:
198
+ out.print(f"[bold]{leak.path}:{leak.line}[/] [red]{leak.rule}[/] {leak.message}")
199
+ if leaks:
200
+ out.print(
201
+ f"\n[red]{len(leaks)} possible secret(s).[/] Use {{{{secret:...}}}} references instead."
202
+ )
203
+ raise typer.Exit(1)
204
+ out.print("[green]No literal secrets found.[/]")
205
+
206
+
207
+ EXAMPLE_ENV = """{
208
+ "dev": {
209
+ "baseUrl": "https://httpbin.org",
210
+ "token": "{{secret:keychain/demo-token}}"
211
+ }
212
+ }
213
+ """
214
+
215
+ EXAMPLE_HTTP = """# Requests are plain text: commit them, review them in pull requests, share them.
216
+ # @name get-example
217
+ GET {{baseUrl}}/get?hello=world
218
+ Accept: application/json
219
+
220
+ ###
221
+
222
+ # The token comes from your OS keychain. Add it with: librepost secret set demo-token
223
+ # @name bearer-example
224
+ GET {{baseUrl}}/bearer
225
+ Authorization: Bearer {{token}}
226
+
227
+ ###
228
+
229
+ # @name post-example
230
+ POST {{baseUrl}}/post
231
+ Content-Type: application/json
232
+
233
+ {
234
+ "id": "{{$uuid}}",
235
+ "sentAt": "{{$isoTimestamp}}"
236
+ }
237
+ """
238
+
239
+
240
+ @app.command()
241
+ def init(
242
+ directory: Annotated[Path, typer.Argument(file_okay=False)] = Path("."),
243
+ ) -> None:
244
+ """Create an example environment file and requests, and gitignore the private env file."""
245
+ directory.mkdir(parents=True, exist_ok=True)
246
+ written = []
247
+ for rel, text in (
248
+ (environments.PUBLIC_FILE, EXAMPLE_ENV),
249
+ ("requests/example.http", EXAMPLE_HTTP),
250
+ ):
251
+ path = directory / rel
252
+ if path.exists():
253
+ out.print(f"[dim]Kept existing {rel}[/]")
254
+ continue
255
+ path.parent.mkdir(parents=True, exist_ok=True)
256
+ path.write_text(text, encoding="utf-8", newline="\n")
257
+ written.append(rel)
258
+
259
+ gitignore = directory / ".gitignore"
260
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
261
+ if environments.PRIVATE_FILE not in existing.splitlines():
262
+ prefix = "" if not existing or existing.endswith("\n") else "\n"
263
+ with gitignore.open("a", encoding="utf-8", newline="\n") as f:
264
+ f.write(
265
+ f"{prefix}# librepost: per-machine values, never committed\n{environments.PRIVATE_FILE}\n"
266
+ )
267
+ written.append(".gitignore")
268
+
269
+ for rel in written:
270
+ out.print(f"[green]Wrote[/] {rel}")
271
+ out.print("\nNext: [bold]librepost ui[/] (and optionally: librepost secret set demo-token)")
272
+
273
+
274
+ @secret_app.command("set")
275
+ def secret_set(name: Annotated[str, typer.Argument(help="Secret name.")]) -> None:
276
+ """Store a secret in the OS keychain. The value is read without echoing it."""
277
+ value = (
278
+ getpass.getpass(f"Value for {name}: ")
279
+ if sys.stdin.isatty()
280
+ else sys.stdin.readline().rstrip("\n")
281
+ )
282
+ if not value:
283
+ err.print("[red]Empty value; nothing stored.[/]")
284
+ raise typer.Exit(2)
285
+ try:
286
+ secretstore.store(name, value)
287
+ except SecretError as exc:
288
+ err.print(f"[red]{exc}[/]")
289
+ raise typer.Exit(1) from None
290
+ out.print(f"[green]Stored[/] {name}. Use it as {{{{secret:keychain/{name}}}}}")
291
+
292
+
293
+ @secret_app.command("delete")
294
+ def secret_delete(name: Annotated[str, typer.Argument(help="Secret name.")]) -> None:
295
+ """Remove a secret from the OS keychain."""
296
+ try:
297
+ secretstore.delete(name)
298
+ except SecretError as exc:
299
+ err.print(f"[red]{exc}[/]")
300
+ raise typer.Exit(1) from None
301
+ out.print(f"[green]Deleted[/] {name}.")
@@ -0,0 +1,71 @@
1
+ """Environments, in the JetBrains HTTP Client format.
2
+
3
+ `http-client.env.json` is committed and holds shared, non-secret values.
4
+ `http-client.private.env.json` is gitignored and overrides it per machine:
5
+
6
+ {
7
+ "$shared": {"apiVersion": "v2"},
8
+ "dev": {"baseUrl": "https://dev.example.com", "token": "{{secret:keychain/dev-token}}"},
9
+ "prod": {"baseUrl": "https://api.example.com"}
10
+ }
11
+
12
+ `$shared` values apply to every environment.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ PUBLIC_FILE = "http-client.env.json"
21
+ PRIVATE_FILE = "http-client.private.env.json"
22
+ SHARED = "$shared"
23
+
24
+
25
+ class EnvFileError(ValueError):
26
+ pass
27
+
28
+
29
+ def _read(path: Path) -> dict[str, dict[str, str]]:
30
+ if not path.is_file():
31
+ return {}
32
+ try:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
35
+ raise EnvFileError(f"Can't read {path.name}: {exc}") from None
36
+ if not isinstance(data, dict):
37
+ raise EnvFileError(f"{path.name} must contain a JSON object of environments.")
38
+ result: dict[str, dict[str, str]] = {}
39
+ for env, values in data.items():
40
+ if not isinstance(values, dict):
41
+ raise EnvFileError(f"{path.name}: environment {env!r} must be an object.")
42
+ result[env] = {str(k): _to_text(v) for k, v in values.items()}
43
+ return result
44
+
45
+
46
+ def _to_text(value: object) -> str:
47
+ if isinstance(value, str):
48
+ return value
49
+ return json.dumps(value)
50
+
51
+
52
+ def load(root: Path) -> dict[str, dict[str, str]]:
53
+ """All environments under `root`, with private values overriding public ones."""
54
+ merged = _read(root / PUBLIC_FILE)
55
+ for env, values in _read(root / PRIVATE_FILE).items():
56
+ merged.setdefault(env, {}).update(values)
57
+ return merged
58
+
59
+
60
+ def names(root: Path) -> list[str]:
61
+ return sorted(env for env in load(root) if env != SHARED)
62
+
63
+
64
+ def variables(root: Path, env: str | None) -> dict[str, str]:
65
+ envs = load(root)
66
+ result = dict(envs.get(SHARED, {}))
67
+ if env:
68
+ if env not in envs:
69
+ raise EnvFileError(f"Unknown environment {env!r}.")
70
+ result.update(envs[env])
71
+ return result
librepost/httpfile.py ADDED
@@ -0,0 +1,205 @@
1
+ """Read and write `.http` files, the plain-text request format used by the JetBrains HTTP
2
+ Client and the VS Code REST Client.
3
+
4
+ A file holds one or more requests separated by lines starting with `###`:
5
+
6
+ @baseUrl = https://example.com
7
+
8
+ # @name login
9
+ POST {{baseUrl}}/login
10
+ Content-Type: application/json
11
+
12
+ {"user": "alice"}
13
+
14
+ ###
15
+
16
+ GET {{baseUrl}}/me
17
+
18
+ Supported: `# @name`, `// comments`, `@var = value` file variables, multi-line query
19
+ continuations (indented lines starting with `?` or `&`) and `HTTP/x` suffixes. Response
20
+ handler scripts (`> {% ... %}`) and output redirects (`>> file`, `<> file`) are kept
21
+ when the file is saved, but librepost does not run them.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass, field
28
+
29
+ METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT")
30
+
31
+ _SEPARATOR = re.compile(r"^###(.*)$")
32
+ _REQUEST_LINE = re.compile(
33
+ r"^(?P<method>[A-Z]+)\s+(?P<url>\S.*?)(?:\s+HTTP/\d(?:\.\d)?)?\s*$",
34
+ )
35
+ _COMMENT = re.compile(r"^\s*(#|//)")
36
+ _NAME = re.compile(r"^\s*(?:#|//)\s*@name\s*[= ]\s*(?P<name>\S.*?)\s*$")
37
+ _FILE_VARIABLE = re.compile(r"^@(?P<name>[A-Za-z_][\w.-]*)\s*=\s*(?P<value>.*?)\s*$")
38
+ _URL_CONTINUATION = re.compile(r"^\s+[?&]")
39
+ # Lines that end the body: response handlers and output redirects.
40
+ _TRAILER = re.compile(r"^(> \{%|> \S|>>!? |<> )")
41
+
42
+
43
+ class HttpFileError(ValueError):
44
+ """A request can't be written as valid `.http` text."""
45
+
46
+
47
+ @dataclass
48
+ class Request:
49
+ method: str = "GET"
50
+ url: str = ""
51
+ headers: list[tuple[str, str]] = field(default_factory=list)
52
+ body: str = ""
53
+ name: str = ""
54
+ # Comment lines above the request line (other than `@name`), kept verbatim.
55
+ comments: list[str] = field(default_factory=list)
56
+ # Response handlers and redirects after the body, kept verbatim but never run.
57
+ trailer: str = ""
58
+
59
+ @property
60
+ def label(self) -> str:
61
+ return self.name or f"{self.method} {self.url}"
62
+
63
+
64
+ @dataclass
65
+ class HttpFile:
66
+ requests: list[Request] = field(default_factory=list)
67
+ variables: dict[str, str] = field(default_factory=dict)
68
+
69
+
70
+ def parse(text: str) -> HttpFile:
71
+ result = HttpFile()
72
+ for separator_name, block in _blocks(text):
73
+ request = _parse_block(block, result.variables)
74
+ if request is None:
75
+ continue
76
+ if not request.name and separator_name:
77
+ request.name = separator_name
78
+ result.requests.append(request)
79
+ return result
80
+
81
+
82
+ def _blocks(text: str) -> list[tuple[str, list[str]]]:
83
+ blocks: list[tuple[str, list[str]]] = [("", [])]
84
+ for line in text.replace("\r\n", "\n").split("\n"):
85
+ match = _SEPARATOR.match(line)
86
+ if match:
87
+ blocks.append((match.group(1).strip(), []))
88
+ else:
89
+ blocks[-1][1].append(line)
90
+ return blocks
91
+
92
+
93
+ def _parse_block(lines: list[str], variables: dict[str, str]) -> Request | None:
94
+ request = Request()
95
+ i = 0
96
+ # Comments, names and file variables before the request line.
97
+ while i < len(lines):
98
+ line = lines[i]
99
+ if not line.strip():
100
+ i += 1
101
+ continue
102
+ name = _NAME.match(line)
103
+ if name:
104
+ request.name = name.group("name")
105
+ elif _COMMENT.match(line):
106
+ request.comments.append(line.rstrip())
107
+ else:
108
+ variable = _FILE_VARIABLE.match(line)
109
+ if not variable:
110
+ break
111
+ variables[variable.group("name")] = variable.group("value")
112
+ i += 1
113
+ if i == len(lines):
114
+ return None
115
+
116
+ request_line = lines[i].strip()
117
+ match = _REQUEST_LINE.match(request_line)
118
+ if match and match.group("method") in METHODS:
119
+ request.method = match.group("method")
120
+ request.url = match.group("url")
121
+ else:
122
+ request.url = re.sub(r"\s+HTTP/\d(?:\.\d)?\s*$", "", request_line)
123
+ i += 1
124
+ while i < len(lines) and _URL_CONTINUATION.match(lines[i]):
125
+ request.url += lines[i].strip()
126
+ i += 1
127
+
128
+ while i < len(lines) and lines[i].strip():
129
+ line = lines[i]
130
+ i += 1
131
+ if _COMMENT.match(line):
132
+ continue
133
+ key, sep, value = line.partition(":")
134
+ if sep:
135
+ request.headers.append((key.strip(), value.strip()))
136
+
137
+ body = lines[i + 1 :] if i < len(lines) else []
138
+ for j, line in enumerate(body):
139
+ if _TRAILER.match(line):
140
+ request.trailer = "\n".join(body[j:]).strip("\n")
141
+ body = body[:j]
142
+ break
143
+ request.body = "\n".join(body).strip("\n")
144
+ return request
145
+
146
+
147
+ def validate(request: Request) -> None:
148
+ """Raise HttpFileError if the request would not survive a save and reload."""
149
+ if not request.url.strip():
150
+ raise HttpFileError("The URL is empty.")
151
+ if "\n" in request.url or "\n" in request.name:
152
+ raise HttpFileError("The URL and name must be on one line.")
153
+ if not re.fullmatch(r"[A-Z]+", request.method):
154
+ raise HttpFileError(f"Invalid method: {request.method!r}")
155
+ for key, value in request.headers:
156
+ if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", key):
157
+ raise HttpFileError(f"Invalid header name: {key!r}")
158
+ if "\n" in value:
159
+ raise HttpFileError(f"Header {key} has a line break in its value.")
160
+ for line in request.body.split("\n"):
161
+ if _SEPARATOR.match(line):
162
+ raise HttpFileError("A body line can't start with ###: it separates requests.")
163
+ if _TRAILER.match(line):
164
+ raise HttpFileError(
165
+ f"A body line can't start with {line[:3]!r}: .http files use it for response handlers."
166
+ )
167
+
168
+
169
+ def dumps(httpfile: HttpFile) -> str:
170
+ parts: list[str] = []
171
+ if httpfile.variables:
172
+ parts.append("\n".join(f"@{k} = {v}" for k, v in httpfile.variables.items()))
173
+ for request in httpfile.requests:
174
+ validate(request)
175
+ lines = list(request.comments)
176
+ if request.name:
177
+ lines.append(f"# @name {request.name}")
178
+ lines.append(f"{request.method} {request.url}")
179
+ lines.extend(f"{k}: {v}" for k, v in request.headers)
180
+ if request.body:
181
+ lines.extend(["", request.body])
182
+ if request.trailer:
183
+ lines.extend(["", request.trailer])
184
+ parts.append("\n".join(lines))
185
+ if httpfile.variables and httpfile.requests:
186
+ head, first, *rest = parts
187
+ parts = [f"{head}\n\n{first}", *rest]
188
+ return "\n\n###\n\n".join(parts) + "\n"
189
+
190
+
191
+ def parse_headers(text: str) -> list[tuple[str, str]]:
192
+ """Parse `Name: value` lines, as typed in the editor."""
193
+ headers = []
194
+ for number, line in enumerate(text.replace("\r\n", "\n").split("\n"), start=1):
195
+ if not line.strip():
196
+ continue
197
+ key, sep, value = line.partition(":")
198
+ if not sep or not key.strip():
199
+ raise HttpFileError(f"Header line {number} is not 'Name: value': {line.strip()!r}")
200
+ headers.append((key.strip(), value.strip()))
201
+ return headers
202
+
203
+
204
+ def format_headers(headers: list[tuple[str, str]]) -> str:
205
+ return "\n".join(f"{k}: {v}" for k, v in headers)