Skip to content

Commit 61c70fe

Browse files
committed
v0.2.0 bump, ready for pypi
1 parent 295a771 commit 61c70fe

File tree

8 files changed

+273
-29
lines changed

8 files changed

+273
-29
lines changed

.gitignore

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
.vscode/
22
__ignore__/
3-
.idea/
4-
*.pyc
53
__pycache__/
4+
*.pyc
65
/dist/
76
/*.egg-info/
8-
.mypy_cache/
9-
setup.py
7+
.mypy_cache/

README.md

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,55 @@ A small simple wrapper around the [Mystb.in](https://mystb.in/) API.
55
### Features
66

77
[x] - `POST`ing to the API, which will return the provided url. \
8-
[x] - `GET`ting from the API, provided you know the URL or paste ID. \
9-
[ ] - Ability to pass in a sync or async session / parameter so it is flexible.
8+
[ ] - `GET`ting from the API, provided you know the URL or paste ID. \
9+
[x] - Ability to pass in a sync or async session / parameter so it is flexible.
1010

11-
[ ] - Write a real underlying Client for this, it will be required for... \
11+
[x] - Write a real underlying Client for this, it will be required for... \
1212
[ ] - Authorization. Awaiting the API making this public as it is still WIP. \
1313

14+
### Installation
15+
This project will be on [PyPI](https://pypi.org/project/mystbin.py/) as a stable release, you can always find that there.
16+
17+
Installing via `pip`:
18+
```shell
19+
python -m pip install -U mystbin.py
20+
# or for optional sync addon...
21+
python -m pip install -U mystbin.py[requests]
22+
```
23+
24+
Installing from source:
25+
```shell
26+
python -m pip install git+https://github.com/AbstractUmbra/mystbin-py.git #[requests] for sync addon
27+
```
28+
29+
### Usage examples
30+
Since the project is considered multi-sync, it will work in a sync/async environment, see the optional dependency of `requests` below.
31+
32+
```py
33+
# async example - it will default to async
34+
import mystbin
35+
36+
mystbin_client = mystbin.MystClient() ## api_key kwarg for authentication also
37+
38+
await mystbin_client.post("Hello from Mystb.in!", suffix="python")
39+
>>> 'https://mystb.in/<your generated ID>.python'
40+
```
41+
42+
```py
43+
# sync example - we need to pass a session though
44+
import mystbin
45+
import requests
46+
47+
sync_session = requests.Session()
48+
mystbin_client = mystbin.MystClient(session=sync_session) ## optional api_key kwarg also
49+
50+
mystbin_client.post("Hello from sync Mystb.in!", suffix="text")
51+
>>> 'https://mystb.in/<your generated ID>.text'
52+
```
53+
54+
NOTE: the session - aiohttp or requests - will have their default headers changed during init to support the Authorization header with the api key if present, and there is a timeout of 15s for each operation.
55+
1456
### Dependencies
1557

16-
`aiohttp` - required
58+
`aiohttp` - required \
1759
`requests` - optional

mystbin/client.py

Lines changed: 119 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,140 @@
2222
DEALINGS IN THE SOFTWARE.
2323
"""
2424

25-
from typing import Optional, Union
25+
import asyncio
26+
import json
27+
from typing import Awaitable, Callable, Optional, Union
2628

2729
import aiohttp
30+
import requests
2831
import yarl
2932

3033
from .constants import *
3134
from .errors import *
35+
from .objects import *
3236

3337
__all__ = ("MystClient", )
3438

35-
class MystClient():
39+
40+
class MystClient:
3641
"""
3742
Client for interacting with the Mystb.in API.
3843
3944
Attributes
4045
----------
41-
authorization: Optional[:class:`dict`]
42-
Your username | password combination to access the Mystb.in API.
43-
Can be obtained via: #TODO
4446
api_key: Optional[:class:`str`]
45-
Your private API token to access the Mystb.in API.
46-
Can be obtained via: #TODO
47-
session: Optional[Union[:class:`ClientSession`, :class:`Session`]]
47+
Your private API token to access the Mystb.in API.
48+
Can be obtained via: #TODO
49+
session: Optional[Union[:class:`aiohttp.ClientSession`, :class:`requests.Session`]]
50+
Optional session to be passed to the creation of the client.
4851
"""
49-
__slots__ = ("authorization", "api_key", "session")
52+
__slots__ = ("api_key", "session", "_are_we_async")
5053

5154
def __init__(
52-
self,
53-
authorization: dict = None,
54-
api_key: str = None, *,
55-
session: Optional[Union[aiohttp.ClientSession, requests.Session]] = None,
56-
)
55+
self, *,
56+
api_key: str = None,
57+
session: Optional[Union[aiohttp.ClientSession,
58+
requests.Session]] = None
59+
) -> None:
60+
self.api_key = api_key
61+
self._are_we_async = session is None or isinstance(
62+
session, aiohttp.ClientSession)
63+
self.session = self._generate_sync_session(
64+
session) if not self._are_we_async else None
65+
66+
def _generate_sync_session(self, session: requests.Session) -> requests.Session:
67+
""" We will update a :class:`requests.Session` instance with the auth we require. """
68+
# the passed session was found to be 'sync'.
69+
if self.api_key:
70+
session.headers.update({"Authorization": self.api_key})
71+
return session
72+
73+
async def _generate_async_session(self, session: Optional[aiohttp.ClientSession] = None) -> aiohttp.ClientSession:
74+
""" We will update (or create) a :class:`aiohttp.ClientSession` instance with the auth we require. """
75+
if not session:
76+
session = aiohttp.ClientSession(raise_for_status=False,
77+
timeout=aiohttp.ClientTimeout(CLIENT_TIMEOUT))
78+
if self.api_key:
79+
session._default_headers.update(
80+
{"Authorization": self.api_key})
81+
return session
82+
83+
def post(self, content: str, syntax: str = None) -> Union[Paste, Awaitable]:
84+
"""
85+
This will post to the Mystb.in API and return the url.
86+
Can pass an optional suffix for the syntax highlighting.
87+
88+
Attributes
89+
----------
90+
content: :class:`str`
91+
The content you are posting to the Mystb.in API.
92+
syntax: :class:`str`
93+
The optional suffix to append the returned URL which is used for syntax highlighting on the paste.
94+
"""
95+
if self._are_we_async:
96+
return self._perform_async_post(content, syntax)
97+
return self._perform_sync_post(content, syntax)
98+
99+
def _perform_sync_post(self, content: str, syntax: str = None) -> Paste:
100+
""" Sync post request. """
101+
payload = {'meta': [{'index': 0, 'syntax': syntax}]}
102+
response: requests.Response = self.session.post(API_BASE_URL, files={
103+
'data': content, 'meta': (None, json.dumps(payload), 'application/json')})
104+
if response.status_code not in [200, 201]:
105+
raise APIError(response.status_code, response.text)
106+
107+
return Paste(response.json(), syntax)
108+
109+
async def _perform_async_post(self, content: str, syntax: str = None) -> Paste:
110+
""" Async post request. """
111+
if not self.session and self._are_we_async:
112+
self.session = await self._generate_async_session()
113+
multi_part_write = aiohttp.MultipartWriter()
114+
paste_content = multi_part_write.append(content)
115+
paste_content.set_content_disposition("form-data", name="data")
116+
paste_content = multi_part_write.append_json(
117+
{'meta': [{'index': 0, 'syntax': syntax}]}
118+
)
119+
paste_content.set_content_disposition("form-data", name="meta")
120+
async with self.session.post(API_BASE_URL, data=multi_part_write) as response:
121+
status_code = response.status
122+
response_text = await response.text()
123+
if status_code not in [200, 201]:
124+
raise APIError(status_code, response_text)
125+
response_data = await response.json()
126+
127+
return Paste(response_data, syntax)
128+
129+
def get(self, paste_id: str) -> str:
130+
"""
131+
This will perform a GET request against the Mystb.in API and return the url.
132+
Must be passed a valid paste ID or URL.
133+
134+
Attributes
135+
----------
136+
paste_id: :class:`str`
137+
The ID of the paste you are going to retrieve.
138+
"""
139+
paste_id_match = MB_URL_RE.match(paste_id)
140+
if not paste_id_match:
141+
raise BadPasteID("This is an invalid Mystb.in paste ID.")
142+
paste_id = paste_id_match.group('ID')
143+
syntax = paste_id_match.group('syntax')
144+
if not self._are_we_async:
145+
return self._perform_sync_get(paste_id, syntax)
146+
return self._perform_async_get(paste_id, syntax)
147+
148+
def _perform_sync_get(self, paste_id: str, syntax: str = None) -> PasteData:
149+
""" Sync get request. """
150+
response = self.session.get(
151+
f"{API_BASE_URL}/{paste_id}", timeout=CLIENT_TIMEOUT)
152+
paste_data = response.json()
153+
return PasteData(paste_id, paste_data)
154+
155+
async def _perform_async_get(self, paste_id: str, syntax: str = None) -> PasteData:
156+
""" Async get request. """
157+
if not self.session:
158+
self.session = await self._generate_async_session()
159+
async with self.session.get(f"{API_BASE_URL}/{paste_id}", timeout=aiohttp.ClientTimeout(CLIENT_TIMEOUT)) as response:
160+
paste_data = await response.json()
161+
return PasteData(paste_id, paste_data)

mystbin/constants.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
"""
2424

2525
from re import compile
26-
from aiohttp import ClientTimeout
27-
2826

2927
API_BASE_URL = "https://mystb.in/api/pastes"
28+
PASTE_BASE = "https://mystb.in/{}{}"
3029

31-
CLIENT_TIMEOUT = ClientTimeout(total=15.0)
30+
CLIENT_TIMEOUT = 15
3231

33-
# grab the paste id: https://regex101.com/r/qkluDh/1
34-
MB_URL_RE = compile(r"(?:http[s]?://mystb\.in/)?([a-zA-Z]+)(?:.*)")
32+
# grab the paste id: https://regex101.com/r/qkluDh/6
33+
MB_URL_RE = compile(
34+
r"(?:(?:https?://)?mystb\.in/)?(?P<ID>[a-zA-Z]+)(?:\.(?P<syntax>[a-zA-Z0-9]+))?")

mystbin/errors.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,21 @@
2222
DEALINGS IN THE SOFTWARE.
2323
"""
2424

25+
26+
class BadPasteID(ValueError):
27+
""" Bad Paste ID. """
28+
29+
class MystbinException(Exception):
30+
""" Error when interacting with Mystbin. """
31+
32+
class APIError(MystbinException):
33+
__slots__ = ("status_code", "message")
34+
def __init__(self, status_code: int, message: str) -> None:
35+
self.status_code = status_code
36+
self.message = message
37+
38+
def __repr__(self) -> str:
39+
return f"<MystbinError status_code={self.status_code} message={self.message}>"
40+
41+
def __str__(self) -> str:
42+
return self.message

mystbin/objects.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2020 AbstractUmbra
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
25+
import datetime
26+
from textwrap import dedent
27+
28+
from .constants import PASTE_BASE
29+
30+
class Paste:
31+
__slots__ = ("paste_id", "nick", "syntax")
32+
def __init__(self, json_data: dict, syntax: str = None) -> None:
33+
self.paste_id = json_data['pastes'][0]['id']
34+
self.nick = json_data['pastes'][0]['nick']
35+
self.syntax = syntax
36+
37+
def __str__(self) -> str:
38+
""" Cast the Paste to a string for the URL. """
39+
return self.url
40+
41+
def __repr__(self) -> str:
42+
""" Paste repr. """
43+
return f"<Paste id={self.paste_id} nick={self.nick} syntax={self.syntax}>"
44+
45+
@property
46+
def url(self) -> str:
47+
syntax = f".{self.syntax}" if self.syntax else ""
48+
return PASTE_BASE.format(self.paste_id, syntax)
49+
50+
class PasteData:
51+
__slots__ = ("paste_id", "_paste_data", "paste_content", "paste_syntax", "paste_nick", "paste_date")
52+
def __init__(self, paste_id: str, paste_data: dict) -> None:
53+
self.paste_id = paste_id
54+
self._paste_data = paste_data
55+
self.paste_content = paste_data['data']
56+
self.paste_syntax = paste_data['syntax']
57+
self.paste_nick = paste_data['nick']
58+
self.paste_date = paste_data['created_at']
59+
60+
def __str__(self) -> str:
61+
""" We'll return the paste content. Since it's dev stuff, dedent it. """
62+
return self.content
63+
64+
def __repr__(self) -> str:
65+
""" Paste content repr. """
66+
return f"<PasteData id={self.paste_id} nick={self.paste_nick} syntax={self.paste_syntax}>"
67+
68+
@property
69+
def url(self) -> str:
70+
""" The Paste ID's URL """
71+
syntax = f".{self.paste_syntax}" if self.paste_syntax else ""
72+
return PASTE_BASE.format(self.paste_id, syntax)
73+
74+
@property
75+
def created_at(self) -> datetime.datetime:
76+
""" Returns a UTC datetime of when the paste was created. """
77+
return datetime.datetime.strptime(self.paste_date, "%Y-%m-%dT%H:%M:%S.%f")
78+
79+
@property
80+
def content(self) -> str:
81+
""" Return the paste content but dedented correctly. """
82+
return dedent(self.paste_content)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "mystbin.py"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
description = "A small simple wrapper around the mystb.in API."
55
authors = ["AbstractUmbra <Umbra@AbstractUmbra.xyz>"]
66
license = "MIT"

requirements.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)