This commit is contained in:
2022-04-08 11:48:41 +02:00
commit 3492b44f2e
11 changed files with 848 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
env/
__pycache__/
dist/
*.egg-info/

7
LICENSE.txt Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2022 Zev Averbach
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

102
README.md Normal file
View File

@@ -0,0 +1,102 @@
# AVT Fresh!
This is a wrapper of [the roly-poly, not 100% ergonomic Freshbooks web API](https://www.freshbooks.com/api/start). It is far from comprehensive: It was created for the specific client- and invoice-related needs of [Averbach Transcription](https://avtranscription.com).
There are "band-aids" here to work around some of the API's shortcomings.
# The Goodies
## Invoices
`get`, `get_one`, `create`, `send`, `update` and `delete` are the bread and butter functions here.
The `get...` functions return some handy `NamedTuple` instances with helpful attributes, notably `FreshbooksInvoice.lines` which have `Decimal` values where you would hope to find them. Also some lookups for addressing the `FreshbooksLine`s you may be interested in.
Then you have helpers `get_all_draft_invoices`, `get_all_invoices_for_org_name`, `get_all_invoices_for_client_id`, and `get_draft_invoices_for_client_id`.
### Create an Invoice
The signature of `invoice.create` is like so:
```python
def create(
client_id: int,
notes: str,
lines: list[dict],
status: str | int,
contacts: list[dict] | None = None,
po_number=None,
create_date=None,
) -> dict:
```
#### `lines`
The dictionaries must contain entries for `name`, `description`, `unit_cost`, and `qty`.
The values must be JSON-serializable, so no `Decimal`s for example (all strings is fine).
#### `contacts`
Each of these dictionaries should simply be `{'contactid': <contactid>}`.
#### `status`
Status can be any of the `v3_status` values as a `str` or `1` or `4` (draft/paid).
## Clients
`get_all_clients`, `get_one`, `create`, and `delete` are available here.
Once more the `get...` functions return `NamedTuple` instances with some helpful attributes, notably `FreshbooksClient.contacts` and a couple of related lookups (`.contact_id_email_lookup` and `.email_contact_id_lookup`).
Then, `update_contacts`, `delete_contact`, `add_contacts`, `get_freshbooks_client_from_client_id`, `get_freshbooks_client_from_email`, and `get_freshbooks_client_from_org_name`.
# Install
```
pip install avt-fresh
```
# Prerequisites/Configuration
Make yourself a nice little `.env` file in your home directory or wherever you're going to be calling this library's code. It needs to contain:
```bash
FRESHBOOKS_API_CLIENT_ID='blah'
FRESHBOOKS_API_CLIENT_SECRET='blah'
FRESHBOOKS_REDIRECT_URI="https://blah.com/blah"
FRESHBOOKS_ACCOUNT_ID="blah"
```
You can get and set these goodies [here](https://my.freshbooks.com/#/developer). Well, all of them except `FRESHBOOKS_ACCOUNT_ID`, which you can see (there's got to be another way??) by clicking on one of your invoices and grabbing the substring here: `https://my.freshbooks.com/#/invoice/<THIS THING>-1234567`.
Don't tell anyone but `FRESHBOOKS_REDIRECT_URI` can be pretty much anything! See `Initializing` below 👇.
## Security
Which brings me to an important point. Currently it's going to save your OAuth tokens in the ever-so-insecure path of `~/freshbooks_oauth_token.json`. TODO: don't do this anymore. ¯\_(ツ)_/¯
# Initializing
When you first call one of the functions which touches the Freshbooks API, you'll be prompted in the terminal like so:
```
Please go here and get an auth code: https://my.freshbooks.com/#/developer, then enter it here:
```
If you don't have an app there, create a really basic one. Name and description can be whatever, and you can skip the URL fields.
Application Type: "Private App"
Scopes: `admin:all:legacy`
Add a redirect URI, it can actually be pretty much anything.
Finally, once you have an app on that developer page, click into it and click "go to authentication" page. Freshbooks will pop open a tab and go to your redirect URI, appending `?code=blahblahblah` to it. Grab the "blah blah blah" value and paste it into the prompt.
You should only have to do this once in each environment you use this library in.
# Hardcoded Stuff / TODOs
Here are some quirks and TODOs. PRs are welcome!:
Currently all invoices will have Stripe added as a payment option. There's no option to skip this, and there's no other payment methods available in this library.
Also, only Python 3.10 is supported at the moment.
Then, when it comes to invoice statuses, we're only using `v3_status` strings, not the numbers. What's more, when you create an invoice we're only supporting two possible statuses: "draft" and "paid".
The `create`, `update`, and `delete` functions return dictionaries rather than an instance of the appropriate `NamedTuple`. This would be a great improvement!
The docs need improvement for sure: For now, have a peek at the source code, which includes pretty comprehensive type hints at the very least.
There are no tests! Is the other thing. Although this code has been used in production in at least one company with some success.

171
avt_fresh/__init__.py Normal file
View File

@@ -0,0 +1,171 @@
import datetime as dt
import json
import os
from dotenv import load_dotenv, find_dotenv
import requests
from avt_fresh.token import Token, NoToken
dotenv_path = find_dotenv()
if not dotenv_path:
dotenv_path = os.getcwd() + "/.env"
load_dotenv(dotenv_path)
BASE_URL = "https://api.freshbooks.com"
URL = f"{BASE_URL}/auth/oauth/token"
HEADERS = {"Content-Type": "application/json"}
CLIENT_ID = os.getenv("FRESHBOOKS_API_CLIENT_ID")
SECRET = os.getenv("FRESHBOOKS_API_CLIENT_SECRET")
ACCOUNT_ID = os.getenv("FRESHBOOKS_ACCOUNT_ID")
REDIRECT_URI = os.getenv("FRESHBOOKS_REDIRECT_URI")
class ReRun(Exception):
pass
class AvtFreshException(Exception):
pass
def make_headers():
return {**HEADERS, "Authorization": f"Bearer {_get_access_token()}"}
REQUEST_LOOKUP = {
"GET": (requests.get, "params"),
"PUT": (requests.put, "json"),
"POST": (requests.post, "json"),
}
def REQUEST(url, method_name: str, endpoint: str, stuff=None):
method, arg_name = REQUEST_LOOKUP[method_name]
if endpoint == "" or endpoint.startswith("?"):
rendered_url = f"{url}{endpoint}"
else:
rendered_url = f"{url}/{endpoint}"
_, the_rest = rendered_url.split("https://")
if "//" in the_rest:
the_rest = the_rest.replace("//", "/")
rendered_url = f"https://{the_rest}"
print(f"{rendered_url=}")
raw_response = method(
rendered_url,
**{
arg_name: stuff or {},
"headers": make_headers(),
},
)
if not raw_response.ok:
raise Exception(
f"response: {raw_response.reason}\nrendered_url: '{rendered_url}'\nstuff:{stuff}"
)
try:
response = raw_response.json()["response"]
except KeyError:
return raw_response.json()
if "result" in response:
return response["result"]
raise Exception(
f"response: {response}\nrendered_url: '{rendered_url}'\nstuff:{stuff}"
)
def _get_access_token(authorization_code: str | None = None) -> str:
if authorization_code:
token = _get_token_from_api_with_authorization_code(
authorization_code=authorization_code
)
_save_token(token)
return token["access_token"]
try:
token = Token.get()
except NoToken:
auth_code = _get_code_from_user()
token_dict = _get_token_from_api_with_authorization_code(auth_code)
_save_token(token_dict)
token = Token.get()
if not _is_expired(token):
return token.access_token
old_token = token
try:
token = _get_token_from_api_with_refresh_token(
refresh_token=old_token.refresh_token
)
except AvtFreshException:
auth_code = _get_code_from_user()
token_dict = _get_token_from_api_with_authorization_code(auth_code)
old_token.delete()
_save_token(token_dict)
token = Token.get()
return token.access_token
else:
old_token.delete()
_save_token(token)
return token["access_token"]
def _get_code_from_user() -> str:
return input(
"Please go here and get an auth code: "
"https://my.freshbooks.com/#/developer, then enter it here: "
)
def _save_token(token_response: dict):
Token.make_from_dict(token_response)
def _is_expired(token: Token) -> bool:
return dt.datetime.now().timestamp() > token.created_at + token.expires_in
def _get_token_from_api_with_authorization_code(authorization_code: str) -> dict:
payload = {
"client_secret": SECRET,
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code", # get this by visiting
"code": authorization_code,
}
res = requests.post(URL, data=json.dumps(payload), headers=HEADERS)
return _return_or_raise(res, payload)
def _return_or_raise(response: requests.Response, payload: dict) -> dict:
response_json = response.json()
if "error" in response_json:
raise AvtFreshException(f"{payload}:\n\n{response_json['error_description']}")
del response_json["direct_buy_tokens"]
return response_json
def _get_token_from_api_with_refresh_token(refresh_token: str) -> dict:
"""
:return:
{
'access_token': <access_token: str>,
'token_type': 'Bearer',
'expires_in': <seconds: int>,
'refresh_token': <refresh_token: str>,
'scope': 'admin:all:legacy',
'created_at': <timestamp>,
'direct_buy_tokens': {}
}
"""
payload = {
"client_secret": SECRET,
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
res = requests.post(URL, data=json.dumps(payload), headers=HEADERS)
return _return_or_raise(res, payload)

177
avt_fresh/client.py Normal file
View File

@@ -0,0 +1,177 @@
from functools import partial
from typing import NamedTuple
import requests
from avt_fresh import BASE_URL, REQUEST as __REQUEST, ACCOUNT_ID, make_headers
class FreshbooksContact(NamedTuple):
contact_id: int
first_name: str
last_name: str
email: str
@classmethod
def from_api(cls, contactid, fname, lname, email, **_):
return cls(contact_id=contactid, first_name=fname, last_name=lname, email=email)
@property
def dict(self):
return {
"email": self.email,
"fname": self.first_name,
"lname": self.last_name,
}
class FreshbooksClient(NamedTuple):
client_id: int
email: str
organization: str
first_name: str
last_name: str
contacts: dict[str, FreshbooksContact]
contact_id_email_lookup: dict[int, str]
email_contact_id_lookup: dict[str, int]
@classmethod
def from_api(cls, fname, lname, organization, userid, email, contacts, **_):
contacts = {
contact["email"]: FreshbooksContact.from_api(**contact)
for contact in contacts
}
contact_id_email_lookup = {
contact.contact_id: email for email, contact in contacts.items()
}
email_contact_id_lookup = {v: k for k, v in contact_id_email_lookup.items()}
return cls(
client_id=userid,
email=email,
first_name=fname,
last_name=lname,
organization=organization,
contacts=contacts,
contact_id_email_lookup=contact_id_email_lookup,
email_contact_id_lookup=email_contact_id_lookup,
)
URL = f"{BASE_URL}/accounting/account/{ACCOUNT_ID}/users/clients"
_REQUEST = partial(__REQUEST, url=URL)
def _GET(endpoint, params=None):
return _REQUEST(method_name="GET", endpoint=endpoint, stuff=params)
def _POST(endpoint, data: dict):
return _REQUEST(method_name="POST", endpoint=endpoint, stuff=data)
def _PUT(endpoint, thing_id: int, data: dict):
return _REQUEST(method_name="PUT", endpoint=f"{endpoint}/{thing_id}", stuff=data)
class NoResult(Exception):
pass
class MoreThanOne(Exception):
pass
INCLUDE = "include[]=contacts"
def get_freshbooks_client_from_email(email: str) -> FreshbooksClient:
try:
return get_one(_GET(f"?search[email]={email}&{INCLUDE}"))
except NoResult as e:
raise NoResult(email) from e
def get_freshbooks_client_from_org_name(org_name: str) -> FreshbooksClient:
return get_one(_GET(f"?search[organization_like]={org_name}&{INCLUDE}"))
def get_freshbooks_client_from_client_id(client_id: int) -> FreshbooksClient:
return FreshbooksClient.from_api(**_GET(f"{client_id}?{INCLUDE}")["client"])
def get_one(response: dict) -> FreshbooksClient:
clients = [FreshbooksClient.from_api(**client) for client in response["clients"]]
if len(clients) > 1:
print("warning, more than one result, returning the first")
elif not clients:
raise NoResult
return clients[0]
def get_all_clients() -> list[FreshbooksClient]:
return _GET(f"?{INCLUDE}")
def delete(client_id) -> None:
requests.put(
f"{BASE_URL}/accounting/account/{client_id}",
json={"client": {"vis_state": 1}},
headers=make_headers(),
)
def create(
first_name: str, last_name: str, email: str, organization: str
) -> FreshbooksClient:
data = {
"client": dict(
fname=first_name, lname=last_name, email=email, organization=organization
)
}
client_id = _POST(endpoint="", data=data)["client"]["id"]
return get_freshbooks_client_from_client_id(client_id)
def add_contacts(client_id, contacts: list[dict]) -> None:
"""contacts: [dict(email, fname, lname)]"""
to_update = []
new_contacts_email_dict = {c["email"]: c for c in contacts}
current_contacts = get_freshbooks_client_from_client_id(client_id).contacts
if current_contacts:
for email, current_contact in current_contacts.items():
new_contact = new_contacts_email_dict.get(email)
if new_contact is None:
to_update.append(current_contact)
else:
to_update.append(new_contact)
del new_contacts_email_dict[new_contact["email"]]
to_update += list(new_contacts_email_dict.values())
update_contacts(client_id, to_update)
def delete_contact(client_id, email) -> None:
client = get_freshbooks_client_from_client_id(client_id)
contact_to_delete = client.contacts.get(email)
if contact_to_delete is not None:
return update_contacts(
client_id,
[
client.dict
for client in client.contacts.values()
if client != contact_to_delete
],
)
def update_contacts(client_id, contacts: list[dict]) -> None:
_update_freshbooks_client(client_id, {"contacts": contacts})
def _update_freshbooks_client(client_id, data: dict) -> None:
requests.put(
f"{BASE_URL}/accounting/account/{client_id}",
json={"client": data},
headers=make_headers(),
)

271
avt_fresh/invoice.py Normal file
View File

@@ -0,0 +1,271 @@
import datetime as dt
from decimal import Decimal
from functools import partial, lru_cache
from typing import NamedTuple
from avt_fresh import BASE_URL, ACCOUNT_ID, REQUEST as __REQUEST
URL = f"{BASE_URL}/accounting/account/{ACCOUNT_ID}/invoices/invoices"
_REQUEST = partial(__REQUEST, url=URL)
class ArgumentError(Exception):
pass
class MoreThanOne(Exception):
pass
class DoesntExist(Exception):
pass
class InvalidField(Exception):
pass
def _GET(endpoint, params=None):
return _REQUEST(method_name="GET", endpoint=endpoint, stuff=params)
def _POST(endpoint, data: dict):
return _REQUEST(method_name="POST", endpoint=endpoint, stuff=data)
def _PUT(endpoint, thing_id: int, data: dict):
return _REQUEST(method_name="PUT", endpoint=f"{endpoint}/{thing_id}", stuff=data)
class FreshbooksLine(NamedTuple):
invoice_id: int
client_id: int
description: str
name: str
rate: Decimal
line_id: int
quantity: Decimal
amount: Decimal
@classmethod
def from_api(cls, **kwargs):
return cls(
invoice_id=kwargs["invoice_id"],
client_id=kwargs["client_id"],
rate=Decimal(kwargs["unit_cost"]["amount"]),
description=kwargs["description"],
name=kwargs["name"],
quantity=Decimal(kwargs["qty"]),
line_id=kwargs["lineid"],
amount=Decimal(kwargs["amount"]["amount"]),
)
@property
def dict_(self):
return {
"name": self.name,
"description": self.description,
"unit_cost": str(self.rate),
"quantity": str(self.quantity),
}
class FreshbooksInvoice(NamedTuple):
lines: list[FreshbooksLine]
notes: str
client_id: int
date: dt.date
invoice_id: int
number: str
organization: str
amount: Decimal
status: str
amount_outstanding: Decimal
po_number: str
line_id_line_dict: dict
line_description_line_dict: dict
line_description_line_id_dict: dict
contacts: dict[str, dict]
allowed_gateways: list
@classmethod
def from_api(cls, **kwargs):
lines = [
FreshbooksLine.from_api(
invoice_id=kwargs["id"], client_id=kwargs["customerid"], **line
)
for line in kwargs["lines"]
]
line_description_line_id_dict = {
line.description: line.line_id for line in lines
}
line_id_line_dict = {line.line_id: line for line in lines}
line_description_line_dict = {line.description: line for line in lines}
return cls(
lines=lines,
line_description_line_id_dict=line_description_line_id_dict,
line_description_line_dict=line_description_line_dict,
line_id_line_dict=line_id_line_dict,
notes=kwargs["notes"],
client_id=kwargs["customerid"],
date=dt.date.fromisoformat(kwargs["create_date"]),
invoice_id=kwargs["id"],
po_number=kwargs["po_number"],
number=kwargs["invoice_number"],
organization=kwargs["organization"],
allowed_gateways=kwargs["allowed_gateways"],
amount=Decimal(kwargs["amount"]["amount"]),
amount_outstanding=Decimal(kwargs["outstanding"]["amount"]),
contacts={contact["email"]: contact for contact in kwargs["contacts"]},
status=kwargs["v3_status"],
)
def get_all_draft_invoices():
return get(status="draft")
def get_all_invoices_for_org_name(org_name: str) -> list[FreshbooksInvoice]:
return get(org_name=org_name)
@lru_cache
def get_all_invoices_for_client_id(client_id: int) -> list[FreshbooksInvoice]:
return get(client_id=client_id)
def get_draft_invoices_for_client_id(client_id: int) -> list[FreshbooksInvoice]:
return get(client_id=client_id, status="draft")
def get_one(invoice_id) -> FreshbooksInvoice:
invoices = get(invoice_id)
if invoices:
if len(invoices) > 1:
raise MoreThanOne
return invoices[0]
raise DoesntExist
def get(
invoice_id=None,
client_id=None,
org_name=None,
status=None,
) -> list[FreshbooksInvoice]:
if client_id is not None and org_name is not None:
raise ArgumentError("Please provide either client_id or org_name")
if invoice_id is not None and any(
arg is not None for arg in (client_id, org_name, status)
):
raise ArgumentError(
"Please provide invoice_id and no other args, or else don't provide invoice_id"
)
full_url = ""
sep = "?"
if invoice_id is not None:
full_url += f"/{invoice_id}"
if client_id is not None:
full_url += f"{sep}search[customerid]={client_id}"
sep = "&"
if status is not None:
full_url += f"{sep}search[v3_status]={status}"
sep = "&"
response = _GET(full_url)
try:
num_results = response["total"]
except KeyError:
full_url += (
f"{sep}include[]=lines&include[]=contacts&include[]=allowed_gateways"
)
else:
if not num_results:
return []
full_url += (
f"{sep}per_page={num_results}"
f"&include[]=lines&include[]=contacts&include[]=allowed_gateways"
)
result = _GET(full_url)
if "invoice" in result:
return [FreshbooksInvoice.from_api(**result["invoice"])]
invoices = result["invoices"]
if org_name is not None:
invoices = [i for i in invoices if org_name == i["current_organization"]]
fb_invoices = []
for invoice in invoices:
try:
fb_invoice = FreshbooksInvoice.from_api(**invoice)
except ValueError as e:
raise InvalidField(f"{invoice}") from e
else:
fb_invoices.append(fb_invoice)
return fb_invoices
STATUS_STRING_INT_LOOKUP = {
"draft": 1,
"paid": 4,
}
def create(
client_id: int,
notes: str,
lines: list[dict],
status: str | int,
contacts: list[dict] | None = None,
po_number=None,
create_date=None,
) -> dict:
"""
`lines`
The dictionaries must contain entries for `name`, `description`, `unit_cost`, and `qty`.
The values must be JSON-serializable, so no `Decimal`s for example (all strings is fine).
`contacts`
Each of these dictionaries should simply be `{'contactid': <contactid>}`.
`status`
Status can be any of the `v3_status` values as a `str` or `1` or `4` (draft/paid).
"""
create_date = create_date or str(dt.date.today())
if isinstance(status, str):
status = STATUS_STRING_INT_LOOKUP[status]
data = dict(
invoice=dict(
customerid=client_id, # type: ignore
notes=notes,
status=status, # type: ignore
lines=lines, # type: ignore
create_date=create_date,
allowed_gateway_name="Stripe",
)
)
if contacts:
data["invoice"]["contacts"] = contacts
if po_number:
data["invoice"]["po_number"] = po_number
return _POST("", data=data)
def update(invoice_id, **kwargs) -> dict:
return _PUT("", invoice_id, dict(invoice=kwargs))
def delete(invoice_id) -> dict:
return _PUT("", invoice_id, dict(invoice=dict(vis_state=1)))
def send(invoice_id) -> dict:
return _PUT("", invoice_id, {"invoice": {"action_email": True}})

30
avt_fresh/payments.py Normal file
View File

@@ -0,0 +1,30 @@
from functools import partial
from . import BASE_URL, ACCOUNT_ID, REQUEST as __REQUEST
URL = f"{BASE_URL}/payments/account/{ACCOUNT_ID}"
_REQUEST = partial(__REQUEST, url=URL)
def _GET(endpoint, params=None):
return _REQUEST(method_name="GET", endpoint=endpoint, stuff=params)
def _POST(endpoint, data: dict):
return _REQUEST(method_name="POST", endpoint=endpoint, stuff=data)
def get_default_payment_options():
return _GET("payment_options?entity_type=invoice")
def add_payment_option_to_invoice(invoice_id, gateway_name="stripe"):
return _POST(
f"invoice/{invoice_id}/payment_options",
data={
"gateway_name": gateway_name,
"entity_id": invoice_id,
"entity_type": "invoice",
"has_credit_card": True,
},
)

55
avt_fresh/token.py Normal file
View File

@@ -0,0 +1,55 @@
import json
from pathlib import Path
from typing import NamedTuple
from uuid import uuid4
class NoToken(Exception):
pass
class OnlyOneToken(Exception):
pass
class Token(NamedTuple):
id: int
access_token: str
token_type: str
expires_in: int
refresh_token: str
scope: str
created_at: int
@classmethod
def _get_all(cls) -> dict:
TOKEN_PATH = Path("~/freshbooks_oauth_token.json").expanduser()
if not TOKEN_PATH.exists():
raise NoToken
with TOKEN_PATH.open(encoding="utf-8") as fin:
return json.load(fin)
@classmethod
def get(cls) -> "Token":
tokens = cls._get_all()
return cls(**list(tokens.values())[-1])
def delete(self) -> None:
tokens = self._get_all()
if len(tokens) == 0:
raise OnlyOneToken
id_ = self.id
del tokens[id_]
self.make_from_dict(tokens)
@classmethod
def make_from_dict(cls, token_dict: dict) -> None:
TOKEN_PATH = Path("~/freshbooks_oauth_token.json").expanduser()
if not TOKEN_PATH.exists():
print(f"token JSON didn't exist, creating it at {TOKEN_PATH}:")
TOKEN_PATH.touch()
id_ = str(uuid4())
token_dict["id"] = id_
token_dict = {id: token_dict}
with TOKEN_PATH.open("w", encoding="utf-8") as fout:
json.dump(token_dict, fout)

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
certifi==2021.10.8
charset-normalizer==2.0.12
idna==3.3
python-dotenv==0.20.0
requests==2.27.1
urllib3==1.26.9

6
setup.cfg Normal file
View File

@@ -0,0 +1,6 @@
[metadata]
description-file=README.md
license_files=LICENSE.txt
long_description=file: README.md
long_description_content_type=text/markdown

18
setup.py Normal file
View File

@@ -0,0 +1,18 @@
from distutils.core import setup
setup(
name="avt_fresh",
author="Zev Averbach",
author_email="zev@averba.ch",
version="0.0.3",
license="MIT",
python_requires=">3.10.0",
keywords='freshbooks API',
install_requires=[
'requests',
'python-dotenv',
],
packages=[
"avt_fresh",
],
)