pdsx 0.0.1a1__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.
pdsx/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """pdsx - general-purpose cli for atproto record operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.0.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """internal utilities for pdsx."""
pdsx/_internal/auth.py ADDED
@@ -0,0 +1,62 @@
1
+ """authentication utilities for atproto."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Console
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ if TYPE_CHECKING:
13
+ from atproto import AsyncClient
14
+
15
+ console = Console()
16
+
17
+ # silence httpx logs
18
+ logging.getLogger("httpx").setLevel(logging.CRITICAL)
19
+
20
+
21
+ async def login(
22
+ client: AsyncClient,
23
+ handle: str | None = None,
24
+ password: str | None = None,
25
+ *,
26
+ silent: bool = False,
27
+ required: bool = True,
28
+ ) -> bool:
29
+ """authenticate with atproto.
30
+
31
+ Args:
32
+ client: atproto client to authenticate
33
+ handle: user handle
34
+ password: user password
35
+ silent: suppress authentication output
36
+ required: whether authentication is required
37
+
38
+ Returns:
39
+ True if authenticated, False if skipped (when not required)
40
+ """
41
+ if not handle or not password:
42
+ if required:
43
+ console.print(
44
+ "[red]error:[/red] provide --handle/--password or set ATPROTO_HANDLE/ATPROTO_PASSWORD"
45
+ )
46
+ sys.exit(1)
47
+ return False
48
+
49
+ if not silent:
50
+ with Progress(
51
+ SpinnerColumn(),
52
+ TextColumn("[dim]{task.description}"),
53
+ console=console,
54
+ transient=True,
55
+ ) as progress:
56
+ progress.add_task("authenticating...", total=None)
57
+ await client.login(handle, password)
58
+ console.print(f"[dim]✓ authenticated as[/dim] {handle}\n")
59
+ else:
60
+ await client.login(handle, password)
61
+
62
+ return True
@@ -0,0 +1,32 @@
1
+ """configuration management for pdsx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+ from pydantic import Field
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+ # suppress pydantic warning about Field defaults
12
+ warnings.filterwarnings(
13
+ "ignore", category=UserWarning, module="pydantic._internal._generate_schema"
14
+ )
15
+
16
+
17
+ class Settings(BaseSettings):
18
+ """settings for atproto cli."""
19
+
20
+ model_config = SettingsConfigDict(
21
+ env_file=str(Path.cwd() / ".env"),
22
+ env_file_encoding="utf-8",
23
+ extra="ignore",
24
+ case_sensitive=False,
25
+ )
26
+
27
+ atproto_pds_url: str = Field(default="https://bsky.social")
28
+ atproto_handle: str = Field(default="")
29
+ atproto_password: str = Field(default="")
30
+
31
+
32
+ settings = Settings()
@@ -0,0 +1,161 @@
1
+ """display utilities for rich output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ from atproto_client.models.dot_dict import DotDict
9
+ from pydantic import BaseModel
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from pdsx._internal.output import OutputFormat
15
+
16
+ if TYPE_CHECKING:
17
+ from atproto import models
18
+
19
+ console = Console()
20
+
21
+
22
+ def _value_to_dict(value: object) -> dict[str, object]:
23
+ """convert a record value to a dict, handling pydantic models and DotDict."""
24
+ if isinstance(value, BaseModel):
25
+ return value.model_dump(mode="json")
26
+ if isinstance(value, DotDict):
27
+ return value.to_dict()
28
+ if isinstance(value, dict):
29
+ return dict(value)
30
+ # fallback for unknown types
31
+ return {k: v for k, v in value.__dict__.items() if not k.startswith("_")}
32
+
33
+
34
+ def display_records(
35
+ collection: str,
36
+ records: list[models.ComAtprotoRepoListRecords.Record],
37
+ *,
38
+ output_format: OutputFormat,
39
+ ) -> None:
40
+ """display a list of records.
41
+
42
+ Args:
43
+ collection: collection name
44
+ records: list of record objects
45
+ output_format: output format enum
46
+ """
47
+ if not records:
48
+ if output_format != OutputFormat.JSON:
49
+ print(f"no records in {collection}")
50
+ return
51
+
52
+ # json output
53
+ if output_format == OutputFormat.JSON:
54
+ output = []
55
+ for record in records:
56
+ rkey = record.uri.split("/")[-1]
57
+ value_dict = _value_to_dict(record.value)
58
+ output.append(
59
+ {
60
+ "rkey": rkey,
61
+ "uri": record.uri,
62
+ "cid": record.cid,
63
+ **value_dict,
64
+ }
65
+ )
66
+ print(json.dumps(output, indent=2))
67
+ return
68
+
69
+ # compact output (one line per record)
70
+ if output_format == OutputFormat.COMPACT:
71
+ count = len(records)
72
+ plural = "record" if count == 1 else "records"
73
+ print(f"{collection} ({count} {plural})")
74
+ for record in records:
75
+ rkey = record.uri.split("/")[-1]
76
+ value_dict = _value_to_dict(record.value)
77
+ print(f"{rkey}: {json.dumps(value_dict, separators=(',', ':'))}")
78
+ return
79
+
80
+ # rich table output (default) - show only simple fields, truncate long text
81
+ count = len(records)
82
+ plural = "record" if count == 1 else "records"
83
+ table = Table(
84
+ title=f"{collection} ({count} {plural})",
85
+ show_header=True,
86
+ header_style="bold cyan",
87
+ show_lines=False,
88
+ )
89
+ table.add_column("rkey", style="dim", width=13, no_wrap=True)
90
+
91
+ # only show simple (non-dict, non-list) fields in table view
92
+ if records:
93
+ first_value = records[0].value
94
+ value_dict = _value_to_dict(first_value)
95
+
96
+ for key, val in value_dict.items():
97
+ if key not in ("$type", "py_type") and not isinstance(val, dict | list):
98
+ # special handling for text fields - give them more width
99
+ width = 50 if key == "text" else 20
100
+ table.add_column(
101
+ key,
102
+ style="white",
103
+ no_wrap=True,
104
+ overflow="ellipsis",
105
+ max_width=width,
106
+ )
107
+
108
+ for record in records:
109
+ rkey = record.uri.split("/")[-1]
110
+ value_dict = _value_to_dict(record.value)
111
+
112
+ row = [rkey]
113
+ for key, val in value_dict.items():
114
+ if key not in ("$type", "py_type") and not isinstance(val, dict | list):
115
+ cell_val = str(val) if val is not None else ""
116
+ row.append(cell_val)
117
+
118
+ table.add_row(*row)
119
+
120
+ console.print(table)
121
+
122
+
123
+ def display_record(response: models.ComAtprotoRepoGetRecord.Response) -> None:
124
+ """display a single record in a panel.
125
+
126
+ Args:
127
+ response: atproto record response
128
+ """
129
+ table = Table(show_header=False, box=None)
130
+ table.add_column("key", style="cyan")
131
+ table.add_column("value", style="white")
132
+
133
+ table.add_row("uri", response.uri)
134
+ table.add_row("cid", response.cid)
135
+
136
+ value_dict = _value_to_dict(response.value)
137
+ for key, val in value_dict.items():
138
+ if key not in ("$type", "py_type"):
139
+ table.add_row(key, str(val))
140
+
141
+ console.print(Panel(table, title="[bold]record[/bold]", border_style="dim"))
142
+
143
+
144
+ def display_success(operation: str, uri: str, cid: str, collection: str = "") -> None:
145
+ """display a success message.
146
+
147
+ Args:
148
+ operation: operation performed (created, updated, deleted)
149
+ uri: record uri
150
+ cid: record cid
151
+ collection: optional collection name
152
+ """
153
+ title = f"[bold]{collection}[/bold]" if collection else ""
154
+ content = f"[green]✓[/green] {operation}"
155
+
156
+ if uri:
157
+ content += f"\n\n[dim]uri:[/dim] {uri}"
158
+ if cid:
159
+ content += f"\n[dim]cid:[/dim] {cid}"
160
+
161
+ console.print(Panel(content, title=title, border_style="green"))
@@ -0,0 +1,203 @@
1
+ """atproto record operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from typing import TYPE_CHECKING
8
+
9
+ if sys.version_info >= (3, 11):
10
+ from datetime import UTC
11
+ else:
12
+ UTC = timezone.utc
13
+
14
+ if TYPE_CHECKING:
15
+ from atproto import AsyncClient, models
16
+
17
+ from pdsx._internal.types import RecordValue
18
+
19
+
20
+ async def list_records(
21
+ client: AsyncClient,
22
+ collection: str,
23
+ limit: int,
24
+ repo: str | None = None,
25
+ ) -> list[models.ComAtprotoRepoListRecords.Record]:
26
+ """list records in a collection.
27
+
28
+ Args:
29
+ client: authenticated atproto client
30
+ collection: collection name
31
+ limit: maximum number of records
32
+ repo: optional repo DID (defaults to authenticated user)
33
+
34
+ Returns:
35
+ list of records
36
+ """
37
+ target_repo = repo or (client.me.did if client.me else None)
38
+ if not target_repo:
39
+ raise ValueError("no repo specified and not authenticated")
40
+
41
+ response = await client.com.atproto.repo.list_records(
42
+ {
43
+ "repo": target_repo,
44
+ "collection": collection,
45
+ "limit": limit,
46
+ }
47
+ )
48
+
49
+ return response.records
50
+
51
+
52
+ async def get_record(
53
+ client: AsyncClient,
54
+ uri: str,
55
+ ) -> models.ComAtprotoRepoGetRecord.Response:
56
+ """get a specific record.
57
+
58
+ Args:
59
+ client: authenticated atproto client
60
+ uri: record AT-URI
61
+
62
+ Returns:
63
+ record response
64
+ """
65
+ parts = uri.replace("at://", "").split("/")
66
+ if len(parts) != 3:
67
+ raise ValueError(f"invalid URI format: {uri}")
68
+
69
+ repo, collection, rkey = parts
70
+
71
+ return await client.com.atproto.repo.get_record(
72
+ {
73
+ "repo": repo,
74
+ "collection": collection,
75
+ "rkey": rkey,
76
+ }
77
+ )
78
+
79
+
80
+ async def create_record(
81
+ client: AsyncClient,
82
+ collection: str,
83
+ record: dict[str, RecordValue],
84
+ ) -> models.ComAtprotoRepoCreateRecord.Response:
85
+ """create a new record.
86
+
87
+ Args:
88
+ client: authenticated atproto client
89
+ collection: collection name
90
+ record: record data
91
+
92
+ Returns:
93
+ created record response
94
+ """
95
+ if client.me is None:
96
+ raise ValueError("not authenticated")
97
+
98
+ # ensure $type is set
99
+ if "$type" not in record:
100
+ record["$type"] = collection
101
+
102
+ # add createdAt if not present
103
+ if "createdAt" not in record:
104
+ record["createdAt"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
105
+
106
+ return await client.com.atproto.repo.create_record(
107
+ {
108
+ "repo": client.me.did,
109
+ "collection": collection,
110
+ "record": record,
111
+ }
112
+ )
113
+
114
+
115
+ async def update_record(
116
+ client: AsyncClient,
117
+ uri: str,
118
+ updates: dict[str, RecordValue],
119
+ ) -> models.ComAtprotoRepoPutRecord.Response:
120
+ """update an existing record.
121
+
122
+ Args:
123
+ client: authenticated atproto client
124
+ uri: record AT-URI (can be shorthand like 'collection/rkey' if authenticated)
125
+ updates: fields to update
126
+
127
+ Returns:
128
+ updated record response
129
+ """
130
+ # strip at:// prefix if present
131
+ uri_without_prefix = uri.replace("at://", "")
132
+ parts = uri_without_prefix.split("/")
133
+
134
+ # if shorthand format (collection/rkey), use authenticated user's DID
135
+ if len(parts) == 2:
136
+ if not client.me:
137
+ raise ValueError("shorthand URI requires authentication")
138
+ repo = client.me.did
139
+ collection, rkey = parts
140
+ elif len(parts) == 3:
141
+ repo, collection, rkey = parts
142
+ else:
143
+ raise ValueError(f"invalid URI format: {uri}")
144
+
145
+ # get current
146
+ current = await client.com.atproto.repo.get_record(
147
+ {
148
+ "repo": repo,
149
+ "collection": collection,
150
+ "rkey": rkey,
151
+ }
152
+ )
153
+
154
+ # merge
155
+ if isinstance(current.value, dict):
156
+ updated_value = {**current.value, **updates}
157
+ else:
158
+ updated_value = dict(current.value)
159
+ updated_value.update(updates)
160
+
161
+ # put
162
+ return await client.com.atproto.repo.put_record(
163
+ {
164
+ "repo": repo,
165
+ "collection": collection,
166
+ "rkey": rkey,
167
+ "record": updated_value,
168
+ }
169
+ )
170
+
171
+
172
+ async def delete_record(
173
+ client: AsyncClient,
174
+ uri: str,
175
+ ) -> None:
176
+ """delete a record.
177
+
178
+ Args:
179
+ client: authenticated atproto client
180
+ uri: record AT-URI (can be shorthand like 'collection/rkey' if authenticated)
181
+ """
182
+ # strip at:// prefix if present
183
+ uri_without_prefix = uri.replace("at://", "")
184
+ parts = uri_without_prefix.split("/")
185
+
186
+ # if shorthand format (collection/rkey), use authenticated user's DID
187
+ if len(parts) == 2:
188
+ if not client.me:
189
+ raise ValueError("shorthand URI requires authentication")
190
+ repo = client.me.did
191
+ collection, rkey = parts
192
+ elif len(parts) == 3:
193
+ repo, collection, rkey = parts
194
+ else:
195
+ raise ValueError(f"invalid URI format: {uri}")
196
+
197
+ await client.com.atproto.repo.delete_record(
198
+ {
199
+ "repo": repo,
200
+ "collection": collection,
201
+ "rkey": rkey,
202
+ }
203
+ )
@@ -0,0 +1,20 @@
1
+ """output formatting utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from enum import Enum
7
+
8
+
9
+ class OutputFormat(str, Enum):
10
+ """output format options following kubectl/gh conventions."""
11
+
12
+ JSON = "json"
13
+ YAML = "yaml"
14
+ TABLE = "table" # rich table (default for TTY)
15
+ COMPACT = "compact" # one-line per record
16
+
17
+
18
+ def is_tty() -> bool:
19
+ """check if stdout is a terminal."""
20
+ return sys.stdout.isatty()
@@ -0,0 +1,47 @@
1
+ """argument parsing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ def parse_key_value_args(args: list[str]) -> dict[str, Any]:
14
+ """parse key=value arguments into a dict.
15
+
16
+ Args:
17
+ args: list of key=value strings
18
+
19
+ Returns:
20
+ parsed dictionary
21
+
22
+ Examples:
23
+ >>> parse_key_value_args(["name=test", "count=5", "active=true"])
24
+ {'name': 'test', 'count': 5, 'active': True}
25
+ """
26
+ result: dict[str, Any] = {}
27
+ for arg in args:
28
+ if "=" not in arg:
29
+ console.print(f"[red]error:[/red] invalid argument format: {arg}")
30
+ console.print("[dim]use key=value format[/dim]")
31
+ sys.exit(1)
32
+ key, value = arg.split("=", 1)
33
+ # try to parse as json primitives
34
+ if value.lower() == "true":
35
+ result[key] = True
36
+ elif value.lower() == "false":
37
+ result[key] = False
38
+ elif value.lower() == "null":
39
+ result[key] = None
40
+ elif value.isdigit():
41
+ result[key] = int(value)
42
+ else:
43
+ try:
44
+ result[key] = float(value)
45
+ except ValueError:
46
+ result[key] = value
47
+ return result
@@ -0,0 +1,5 @@
1
+ """type aliases for pdsx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ RecordValue = str | int | float | bool | None
pdsx/cli.py ADDED
@@ -0,0 +1,223 @@
1
+ """general-purpose cli for atproto record operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ import warnings
9
+ from typing import NoReturn
10
+
11
+ # suppress pydantic warnings from atproto library
12
+ warnings.filterwarnings("ignore", category=UserWarning, module="pydantic")
13
+
14
+ from atproto import AsyncClient # noqa: E402
15
+
16
+ from pdsx._internal.auth import login # noqa: E402
17
+ from pdsx._internal.config import settings # noqa: E402
18
+ from pdsx._internal.display import ( # noqa: E402
19
+ console,
20
+ display_record,
21
+ display_records,
22
+ display_success,
23
+ )
24
+ from pdsx._internal.operations import ( # noqa: E402
25
+ create_record,
26
+ delete_record,
27
+ get_record,
28
+ list_records,
29
+ update_record,
30
+ )
31
+ from pdsx._internal.output import OutputFormat # noqa: E402
32
+ from pdsx._internal.parsing import parse_key_value_args # noqa: E402
33
+ from pdsx._internal.types import RecordValue # noqa: E402
34
+
35
+
36
+ async def cmd_list(
37
+ client: AsyncClient,
38
+ collection: str,
39
+ limit: int,
40
+ repo: str | None = None,
41
+ output_format: OutputFormat | None = None,
42
+ ) -> None:
43
+ """list records in a collection."""
44
+ records = await list_records(client, collection, limit, repo)
45
+
46
+ # determine output format - default to compact (most readable for most data)
47
+ fmt = output_format or OutputFormat.COMPACT
48
+
49
+ display_records(collection, records, output_format=fmt)
50
+
51
+
52
+ async def cmd_get(client: AsyncClient, uri: str) -> None:
53
+ """get a specific record."""
54
+ response = await get_record(client, uri)
55
+ display_record(response)
56
+
57
+
58
+ async def cmd_create(
59
+ client: AsyncClient,
60
+ collection: str,
61
+ record: dict[str, RecordValue],
62
+ ) -> None:
63
+ """create a new record."""
64
+ response = await create_record(client, collection, record)
65
+ display_success("created", response.uri, response.cid, collection)
66
+
67
+
68
+ async def cmd_update(
69
+ client: AsyncClient,
70
+ uri: str,
71
+ updates: dict[str, RecordValue],
72
+ ) -> None:
73
+ """update an existing record."""
74
+ response = await update_record(client, uri, updates)
75
+ display_success("updated", response.uri, response.cid)
76
+
77
+
78
+ async def cmd_delete(client: AsyncClient, uri: str) -> None:
79
+ """delete a record."""
80
+ await delete_record(client, uri)
81
+ display_success("deleted", "", "")
82
+
83
+
84
+ async def async_main() -> int:
85
+ """main entry point."""
86
+ parser = argparse.ArgumentParser(
87
+ description="atproto record operations",
88
+ formatter_class=argparse.RawDescriptionHelpFormatter,
89
+ )
90
+
91
+ # version flag
92
+ parser.add_argument(
93
+ "-v",
94
+ "--version",
95
+ action="version",
96
+ version=f"pdsx {__import__('pdsx').__version__}",
97
+ )
98
+
99
+ # global identity flag - can be handle or DID
100
+ parser.add_argument(
101
+ "-r",
102
+ "--repo",
103
+ help="handle or DID to operate on (for reads without auth, or to specify target repo)",
104
+ )
105
+
106
+ # auth options (only needed for writes to your own repo)
107
+ parser.add_argument("--handle", help="atproto handle for authentication")
108
+ parser.add_argument("--password", help="atproto password for authentication")
109
+ parser.add_argument("--pds", help="pds url (default: https://bsky.social)")
110
+
111
+ subparsers = parser.add_subparsers(dest="command", help="command")
112
+
113
+ # list (ls alias)
114
+ list_parser = subparsers.add_parser("list", aliases=["ls"], help="list records")
115
+ list_parser.add_argument(
116
+ "collection", help="collection name (e.g., app.bsky.feed.post)"
117
+ )
118
+ list_parser.add_argument("--limit", type=int, default=50, help="max records")
119
+ list_parser.add_argument(
120
+ "-o",
121
+ "--output",
122
+ choices=["json", "yaml", "table", "compact"],
123
+ help="output format (default: compact)",
124
+ )
125
+
126
+ # get (cat alias)
127
+ get_parser = subparsers.add_parser("get", aliases=["cat"], help="get record")
128
+ get_parser.add_argument("uri", help="record AT-URI")
129
+
130
+ # create (touch/add aliases)
131
+ create_parser = subparsers.add_parser(
132
+ "create", aliases=["touch", "add"], help="create record"
133
+ )
134
+ create_parser.add_argument("collection", help="collection name")
135
+ create_parser.add_argument(
136
+ "fields",
137
+ nargs="+",
138
+ help="record fields as key=value pairs (e.g., title='My Song' artist='Artist')",
139
+ )
140
+
141
+ # update (edit alias)
142
+ update_parser = subparsers.add_parser(
143
+ "update", aliases=["edit"], help="update record"
144
+ )
145
+ update_parser.add_argument("uri", help="record AT-URI")
146
+ update_parser.add_argument(
147
+ "fields", nargs="+", help="fields to update as key=value pairs"
148
+ )
149
+
150
+ # delete (rm alias)
151
+ delete_parser = subparsers.add_parser(
152
+ "delete", aliases=["rm"], help="delete record"
153
+ )
154
+ delete_parser.add_argument("uri", help="record AT-URI")
155
+
156
+ args = parser.parse_args()
157
+
158
+ if not args.command:
159
+ parser.print_help()
160
+ return 1
161
+
162
+ # update pds if provided
163
+ if args.pds:
164
+ settings.atproto_pds_url = args.pds
165
+
166
+ try:
167
+ client = AsyncClient(base_url=settings.atproto_pds_url)
168
+
169
+ # determine if auth is needed
170
+ # reads with --repo don't need auth, writes always need auth
171
+ read_commands = ("list", "ls", "get", "cat")
172
+ is_read = args.command in read_commands
173
+ has_repo_target = args.repo is not None
174
+
175
+ # auth required if: doing a write operation OR doing a read without --repo
176
+ auth_needed = not is_read or not has_repo_target
177
+
178
+ await login(
179
+ client,
180
+ args.handle or settings.atproto_handle,
181
+ args.password or settings.atproto_password,
182
+ silent=True,
183
+ required=auth_needed,
184
+ )
185
+
186
+ if args.command in ("list", "ls"):
187
+ output_fmt = OutputFormat(args.output) if args.output else None
188
+ await cmd_list(
189
+ client,
190
+ args.collection,
191
+ args.limit,
192
+ args.repo,
193
+ output_format=output_fmt,
194
+ )
195
+
196
+ elif args.command in ("get", "cat"):
197
+ await cmd_get(client, args.uri)
198
+
199
+ elif args.command in ("create", "touch", "add"):
200
+ record = parse_key_value_args(args.fields)
201
+ await cmd_create(client, args.collection, record)
202
+
203
+ elif args.command in ("update", "edit"):
204
+ updates = parse_key_value_args(args.fields)
205
+ await cmd_update(client, args.uri, updates)
206
+
207
+ elif args.command in ("delete", "rm"):
208
+ await cmd_delete(client, args.uri)
209
+
210
+ return 0
211
+
212
+ except Exception as e:
213
+ console.print(f"[red]error:[/red] {e}")
214
+ import os
215
+
216
+ if os.getenv("DEBUG"):
217
+ raise
218
+ return 1
219
+
220
+
221
+ def main() -> NoReturn:
222
+ """synchronous entry point."""
223
+ sys.exit(asyncio.run(async_main()))
pdsx/py.typed ADDED
File without changes
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdsx
3
+ Version: 0.0.1a1
4
+ Summary: general-purpose cli for atproto record operations
5
+ Author-email: zzstoatzz <thrast36@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: atproto,bluesky,cli,pds,records
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: atproto>=0.0.63
22
+ Requires-Dist: pydantic-settings>=2.7.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pdsx
27
+
28
+ general-purpose cli for atproto record operations
29
+
30
+ ## installation
31
+
32
+ ```bash
33
+ uv add pdsx
34
+ # or
35
+ uvx pdsx --help
36
+ ```
37
+
38
+ ## quick start
39
+
40
+ set your credentials:
41
+
42
+ ```bash
43
+ export ATPROTO_HANDLE=your.handle
44
+ export ATPROTO_PASSWORD=your-password
45
+ ```
46
+
47
+ list posts (compact format by default):
48
+
49
+ ```bash
50
+ pdsx ls app.bsky.feed.post --limit 10
51
+ ```
52
+
53
+ json output for jq:
54
+
55
+ ```bash
56
+ pdsx ls app.bsky.feed.post -o json | jq '.[].text'
57
+ ```
58
+
59
+ table view:
60
+
61
+ ```bash
62
+ pdsx ls app.bsky.feed.post -o table
63
+ ```
64
+
65
+ ## features
66
+
67
+ - crud operations for atproto records (list, get, create, update, delete)
68
+ - multiple output formats: compact (default), json, yaml, table
69
+ - unix-style command aliases: `ls`, `cat`, `rm`, `edit`, `touch`/`add`
70
+ - clean json output for jq/pipe interoperability
71
+ - tty-aware formatting
72
+ - type-safe with strict typing
73
+ - python 3.10+ support
74
+
75
+ ## usage
76
+
77
+ ### list records
78
+
79
+ ```bash
80
+ # using full command
81
+ pdsx list app.bsky.feed.post --limit 5
82
+
83
+ # using alias
84
+ pdsx ls app.bsky.feed.post --limit 5 -o json
85
+ ```
86
+
87
+ ### get a record
88
+
89
+ ```bash
90
+ # using full command
91
+ pdsx get at://did:plc:example/app.bsky.feed.post/123
92
+
93
+ # using alias
94
+ pdsx cat at://did:plc:example/app.bsky.feed.post/123
95
+ ```
96
+
97
+ ### create a record
98
+
99
+ ```bash
100
+ pdsx create app.bsky.feed.like subject='at://...' createdAt='2024-01-01T00:00:00Z'
101
+
102
+ # using alias
103
+ pdsx touch app.bsky.feed.like subject='at://...' createdAt='2024-01-01T00:00:00Z'
104
+ ```
105
+
106
+ ### update a record
107
+
108
+ ```bash
109
+ pdsx update at://did:plc:example/app.bsky.feed.post/123 text='updated text'
110
+
111
+ # using alias
112
+ pdsx edit at://did:plc:example/app.bsky.feed.post/123 text='updated text'
113
+ ```
114
+
115
+ ### delete a record
116
+
117
+ ```bash
118
+ pdsx delete at://did:plc:example/app.bsky.feed.post/123
119
+
120
+ # using alias
121
+ pdsx rm at://did:plc:example/app.bsky.feed.post/123
122
+ ```
123
+
124
+ ## output formats
125
+
126
+ ### compact (default)
127
+
128
+ one line per record with json:
129
+
130
+ ```
131
+ app.bsky.feed.post (3 records)
132
+ 3m4ryxwq5dt2i: {"created_at":"2025-11-04T07:25:17.061883+00:00","text":"..."}
133
+ 3m4ryxvw4l32z: {"created_at":"2025-11-04T07:25:16.201083+00:00","text":"..."}
134
+ ```
135
+
136
+ ### json
137
+
138
+ clean json for piping to jq:
139
+
140
+ ```bash
141
+ pdsx ls app.bsky.feed.post -o json | jq '.[].text'
142
+ ```
143
+
144
+ ### table
145
+
146
+ pretty table for terminal viewing:
147
+
148
+ ```bash
149
+ pdsx ls app.bsky.feed.post -o table
150
+ ```
151
+
152
+ ## examples
153
+
154
+ <details>
155
+ <summary>click to see real-world examples</summary>
156
+
157
+ ### read anyone's bio
158
+ ```bash
159
+ uvx pdsx --repo did:plc:o53crari67ge7bvbv273lxln list app.bsky.actor.profile -o json | \
160
+ jq -r '.[0].description'
161
+ ```
162
+
163
+ ### update your bio
164
+ ```bash
165
+ export ATPROTO_HANDLE=your.handle ATPROTO_PASSWORD=your-password
166
+ uvx pdsx edit app.bsky.actor.profile/self description='new bio'
167
+ ```
168
+
169
+ ### read anyone's posts
170
+ ```bash
171
+ uvx pdsx -r did:plc:o53crari67ge7bvbv273lxln list app.bsky.feed.post --limit 5 -o json | \
172
+ jq -r '.[] | .text'
173
+ ```
174
+
175
+ **atproto gives you full read access to anyone's public data. auth only required for writes.**
176
+
177
+ </details>
178
+
179
+ ## development
180
+
181
+ ```bash
182
+ # clone repo
183
+ git clone https://github.com/zzstoatzz/pdsx
184
+ cd pdsx
185
+
186
+ # install with dev dependencies
187
+ uv sync
188
+
189
+ # run tests
190
+ uv run pytest
191
+
192
+ # run type checker
193
+ uv run ty check
194
+
195
+ # run pre-commit hooks
196
+ uv run prek run --all-files
197
+ ```
198
+
199
+ ## license
200
+
201
+ mit
@@ -0,0 +1,16 @@
1
+ pdsx/__init__.py,sha256=gqc9TP1TpNH5uqkHqkQXIHZ4Sr10buEXAz4Onu5MD6A,150
2
+ pdsx/cli.py,sha256=xxxlWebKrBDHeiZ-ZACb-EEPk1-BNZDrWIPYbZiOlB4,6754
3
+ pdsx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pdsx/_internal/__init__.py,sha256=jx0usJBkEhxakUqVLoMuqqe-eiFWaDFjIP0Q34eNpdk,35
5
+ pdsx/_internal/auth.py,sha256=dhyV4VJ_JWVtEZMwHbk4thvs12dV6fK9n8pUYmqxNtg,1610
6
+ pdsx/_internal/config.py,sha256=rWUPBiOWGzKkm66zvohDmXAqFCf4DXDLA1FvBwyoKsM,793
7
+ pdsx/_internal/display.py,sha256=9SWRu9cNfBGNfFkwqI_lwYm2ZJclm6_ysbxuuuSdFq4,4987
8
+ pdsx/_internal/operations.py,sha256=_JpqLJd2x6S-rgPwmZfqaH5k5BweRJN0A6O839_oArg,5107
9
+ pdsx/_internal/output.py,sha256=Z10r3NplKpa4Zh5KRfJcjdZYuihmpGyYZfB5-F6mWqk,436
10
+ pdsx/_internal/parsing.py,sha256=47zjcR2w1_HBRX-fRc6PxoN3VkOAUoDPd_yj2zYF9So,1268
11
+ pdsx/_internal/types.py,sha256=tXvphASiX0H0_4HGYImQnQs8G7Q-Z2javBKQiOk_Ieg,112
12
+ pdsx-0.0.1a1.dist-info/METADATA,sha256=2e2mXPh6d15tOTzhuhH-5JIC9qTUOdykefAjUtrQNPE,4070
13
+ pdsx-0.0.1a1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ pdsx-0.0.1a1.dist-info/entry_points.txt,sha256=FaACMWIJsfvwWA-vu7N-C0gaQ1BMR0IbMMxGpL3s9LA,39
15
+ pdsx-0.0.1a1.dist-info/licenses/LICENSE,sha256=EB-ZSyrRfegHEfc5eLZQLlQhaD7AL9nsrMoR1D8iIGo,1066
16
+ pdsx-0.0.1a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdsx = pdsx.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 zzstoatzz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.