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 +7 -0
- pdsx/_internal/__init__.py +1 -0
- pdsx/_internal/auth.py +62 -0
- pdsx/_internal/config.py +32 -0
- pdsx/_internal/display.py +161 -0
- pdsx/_internal/operations.py +203 -0
- pdsx/_internal/output.py +20 -0
- pdsx/_internal/parsing.py +47 -0
- pdsx/_internal/types.py +5 -0
- pdsx/cli.py +223 -0
- pdsx/py.typed +0 -0
- pdsx-0.0.1a1.dist-info/METADATA +201 -0
- pdsx-0.0.1a1.dist-info/RECORD +16 -0
- pdsx-0.0.1a1.dist-info/WHEEL +4 -0
- pdsx-0.0.1a1.dist-info/entry_points.txt +2 -0
- pdsx-0.0.1a1.dist-info/licenses/LICENSE +21 -0
pdsx/__init__.py
ADDED
|
@@ -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
|
pdsx/_internal/config.py
ADDED
|
@@ -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
|
+
)
|
pdsx/_internal/output.py
ADDED
|
@@ -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
|
pdsx/_internal/types.py
ADDED
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,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.
|