Skip to content

Commit

Permalink
Squashed commit from develop branch
Browse files Browse the repository at this point in the history
Release 0.7.0
* Adds `urls` package.
* Adds type information to endpoints.
* `bc3 configure` now prompts user to select an account ID if they
  belong to multiple Basecamp3 accounts.
* Changes Docker container to use `slim` instead of `alpine`
* Python 2 imports wrapped in try/catch are replaced with `six.moves`
  instead.
  • Loading branch information
phistrom committed Oct 13, 2021
1 parent 7628d1d commit f238915
Show file tree
Hide file tree
Showing 62 changed files with 3,167 additions and 94 deletions.
38 changes: 20 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
FROM python:3.9-alpine
FROM python:3.9-slim

ARG USER_ID=2000
ARG GROUP_ID="${USER_ID}"
ARG USERNAME=basecampy
ARG USER_HOME=/bc3
ARG WORKDIR=${USER_HOME}
ARG WORKDIR=/bc3
ARG HOME_DIR="/home/${USERNAME}"

ENV WORKDIR="${WORKDIR}"
ENV HOME_DIR="${HOME_DIR}"
ENV BC3_OAUTH_BIND_ADDRESS=0.0.0.0
ENV BC3_CONTAINER=1
ENV BC3_CONFIG_PATH="/etc/basecamp.conf"
ENV PYTHONPATH="${WORKDIR}"

COPY requirements.txt .

RUN pip install -r requirements.txt

WORKDIR "${WORKDIR}"

# temporarily switch to this directory to copy our source code into and install
WORKDIR /usr/src/app
COPY . .

RUN python setup.py install \
&& addgroup -g "${GROUP_ID}" "${USERNAME}" \
&& adduser -u "${USER_ID}" -G ${USERNAME} -h "${USER_HOME}" -s /bin/sh -D "${USERNAME}" \
&& mkdir -p "${WORKDIR}" \
&& mkdir -p "${USER_HOME}/.config" \
&& chown -R "${USERNAME}:${USERNAME}" "${WORKDIR}" "${USER_HOME}"
RUN addgroup --gid ${GROUP_ID} "${USERNAME}" \
&& adduser --home "${HOME_DIR}" --gecos '' --uid "${USER_ID}" --gid "${GROUP_ID}" --disabled-password "${USERNAME}" \
&& ln -s "${WORKDIR}/bc3" "/usr/local/bin/bc3" \
&& touch "${BC3_CONFIG_PATH}" \
&& chown -R "${USERNAME}:${USERNAME}" "${WORKDIR}" "${BC3_CONFIG_PATH}"

USER ${USERNAME}
WORKDIR ${WORKDIR}
RUN python setup.py install

# persist this location if you want to keep your
# credentials when recreating the container
VOLUME ["${USER_HOME}/.config"]
USER ${USERNAME}

# for the bc3 configure command, it must listen on your localhost for
# the redirect URL to receive an authorization token
EXPOSE 33333

ENTRYPOINT ["bc3"]
CMD ["--help"]
CMD ["bc3", "--help"]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ from basecampy3 import Basecamp3
import json

bc3 = Basecamp3()
session = bc3._session
session = bc3.session

# replace these with actual IDs of the Basecamp objects you wish to get
MY_COMPANY_ID = 1234567
Expand Down
2 changes: 1 addition & 1 deletion basecampy3/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.6.1'
__version__ = '0.7.0'
89 changes: 50 additions & 39 deletions basecampy3/bc3_api.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
"""
The Basecamp3 class should be the only thing you need to import to access just about all the functionality you need.
"""
import logging
import os
from datetime import datetime
import dateutil.parser
import pytz
import requests
from .transport_adapter import Basecamp3TransportAdapter

from . import config, constants, exc
from .endpoints import answers
from .endpoints import campfires
from .endpoints import campfire_lines
from .endpoints import messages
from .endpoints import message_boards
from .endpoints import message_categories
from .endpoints import people
from .endpoints import projects
from .endpoints import project_constructions
from .endpoints import templates
from .endpoints import todolists
from .endpoints import todolist_groups
from .endpoints import todos
from .endpoints import todosets
from . import config, constants, endpoints, exc, urls

logger = logging.getLogger(__name__)


def _create_session():
Expand All @@ -33,7 +22,7 @@ def _create_session():

class Basecamp3(object):
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access_token=None, refresh_token=None,
conf=None):
account_id=None, conf=None, api_url=constants.API_URL):
"""
Create a new Basecamp 3 API connection. The following combinations of parameters are valid:
Expand Down Expand Up @@ -72,6 +61,8 @@ def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access
:type access_token: str
:param refresh_token: a refresh token obtained from a user, used for obtaining a new access_token
:type refresh_token: str
:param account_id: the account ID to use (used if the user belongs to multiple Basecamp 3 accounts)
:type account_id: str|int
:param conf: a BasecampConfig object with all the settings we need so that we don't have to fill out all
these parameters
:type conf: basecampy3.config.BasecampConfig
Expand All @@ -83,7 +74,7 @@ def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access
if has_direct_values: # user provided fields in constructor
conf = config.BasecampMemoryConfig(client_id=client_id, client_secret=client_secret,
redirect_uri=redirect_uri, access_token=access_token,
refresh_token=refresh_token)
refresh_token=refresh_token, account_id=account_id)
if not conf.is_usable: # user didn't provide enough fields in constructor
raise ValueError("Unable to use the Basecamp 3 API. Not enough information provided.")
elif conf is None: # user provided no fields at all, look for a saved config file (the preferred way to run)
Expand All @@ -95,24 +86,26 @@ def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access
raise ValueError("Unable to find a suitable Basecamp 3 configuration. Try running `bc3 configure`.")

self._conf = conf
self._session = _create_session()
self._session.mount("https://", adapter=Basecamp3TransportAdapter())
self.account_id = None
session = _create_session()
session.mount("https://", adapter=Basecamp3TransportAdapter())
self.session = self._session = session
self._authorize()
self.answers = answers.Answers(self)
self.campfires = campfires.Campfires(self)
self.campfire_lines = campfire_lines.CampfireLines(self)
self.messages = messages.Messages(self)
self.message_boards = message_boards.MessageBoards(self)
self.message_categories = message_categories.MessageCategories(self)
self.people = people.People(self)
self.projects = projects.Projects(self)
self.project_constructions = project_constructions.ProjectConstructions(self)
self.templates = templates.Templates(self)
self.todolists = todolists.TodoLists(self)
self.todolist_groups = todolist_groups.TodoListGroups(self)
self.todos = todos.Todos(self)
self.todosets = todosets.TodoSets(self)
self.urls = urls.BasecampURLs(self.account_id, api_url)

self.answers = endpoints.Answers(self)
self.campfires = endpoints.Campfires(self)
self.campfire_lines = endpoints.CampfireLines(self)
self.messages = endpoints.Messages(self)
self.message_boards = endpoints.MessageBoards(self)
self.message_categories = endpoints.MessageCategories(self)
self.people = endpoints.People(self)
self.projects = endpoints.Projects(self)
self.project_constructions = endpoints.ProjectConstructions(self)
self.templates = endpoints.Templates(self)
self.todolists = endpoints.TodoLists(self)
self.todolist_groups = endpoints.TodoListGroups(self)
self.todos = endpoints.Todos(self)
self.todosets = endpoints.TodoSets(self)

@classmethod
def from_environment(cls):
Expand Down Expand Up @@ -144,6 +137,13 @@ def who_am_i(self):
data = self._get_data(constants.AUTHORIZATION_JSON_URL, False)
return data.json()

@property
def accounts(self):
identity = self.who_am_i
for acct in identity['accounts']:
if acct['product'] == 'bc3':
yield acct

@classmethod
def trade_user_code_for_access_token(cls, client_id, redirect_uri, client_secret, code, session=None):
"""
Expand Down Expand Up @@ -239,12 +239,23 @@ def _get_account_id(self):
Get the account ID for this user. Returns the first account ID found where the product field is "bc3".
:return: str
"""
# TODO user can belong to multiple accounts. Force user to pick one during bc3 configure phase and save to conf

if self._conf.account_id:
return self._conf.account_id

identity = self.who_am_i
for acct in identity['accounts']:
if acct['product'] == 'bc3':
return acct['id']
raise exc.UnknownAccountIDError("Could not determine this Basecamp account's ID")
accounts = [acct for acct in identity['accounts'] if acct['product'] == 'bc3']
if len(accounts) == 1:
return accounts[0]['id']
elif len(accounts) < 1:
raise exc.UnknownAccountIDError("You do not belong to any Basecamp 3 accounts.")
else:
account = accounts[0]
logger.warning("You belong to more than one Basecamp3 account and you do not have an account_id \n"
"specified in your configuration. Please run `bc3 configure` again to avoid this warning. \n"
"Proceeding with legacy behavior of picking the first account which is %s (ID = %s)..." %
(account['name'], account['id']))
return account['id']

def _is_token_expired(self):
"""
Expand Down
29 changes: 27 additions & 2 deletions basecampy3/bc3_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import os
import traceback

from basecampy3 import Basecamp3
from basecampy3 import Basecamp3, exc
from basecampy3.bc3_api import _create_session
from basecampy3.token_requestor import TokenRequester
from basecampy3 import config, constants
Expand Down Expand Up @@ -88,6 +88,31 @@ def _configure():
print("Success! Your tokens are listed below.")
print("Access Token: %s" % tokens['access_token'])
print("Refresh Token: %s" % tokens['refresh_token'])
bc3api = Basecamp3(access_token=tokens['access_token'])
identity = bc3api.who_am_i["identity"]
accounts = [acct for acct in bc3api.accounts]
if len(accounts) < 1:
print("Error: You don't seem to have any Basecamp accounts")
raise exc.UnknownAccountIDError()
elif len(accounts) == 1:
account_id = accounts[0]["id"]
else:
while True:
print("User ID %s, email %s has %s accounts. Which one do you want to use?" %
(identity["id"], identity["email_address"], len(accounts)))
for idx, acct in enumerate(accounts, start=1):
print("%s) %s (ID = %s)" % (idx, acct["name"], acct["id"]))
choice = input("Which of the above accounts do you want to use? ")
try:
choice = abs(int(choice))
acct = accounts[choice - 1]
account_id = acct["id"]
print("Selected %(name)s (ID = %(id)s)" % acct)
break
except (IndexError, TypeError, ValueError):
print("%s is not a valid choice. Please provide a number between 1 and %s" %
(choice, len(accounts)))

while True:
should_save = input("Do you want to save? (Y/N)").upper().strip()
if should_save in ("Y", "YES"):
Expand All @@ -110,7 +135,7 @@ def _configure():
try:
conf = config.BasecampFileConfig(client_id=client_id, client_secret=client_secret,
redirect_uri=redirect_uri, access_token=tokens['access_token'],
refresh_token=tokens['refresh_token'])
refresh_token=tokens['refresh_token'], account_id=account_id)
conf.save(location)
break
except Exception:
Expand Down
21 changes: 12 additions & 9 deletions basecampy3/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,11 @@
BasecampConfig and some built-in subclasses of it.
"""

try:
# Python 2
from ConfigParser import SafeConfigParser as ConfigParser, NoSectionError, NoOptionError
except ImportError:
# Python 3
from configparser import ConfigParser, NoSectionError, NoOptionError

import abc
import os
import six
from six.moves.configparser import ConfigParser, NoSectionError, NoOptionError

from . import constants, exc
from .log import logger
Expand All @@ -30,10 +25,12 @@ class BasecampConfig(object):
"redirect_uri",
"access_token",
"refresh_token",
"account_id",
]
"""Fields that are expected to be persisted by the save function."""

def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access_token=None, refresh_token=None):
def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access_token=None, refresh_token=None,
account_id=None):
"""
:param client_id: the Client ID for the Basecamp 3 integration to use
:type client_id: str
Expand All @@ -46,12 +43,15 @@ def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access
:param refresh_token: a refresh_token obtained with the initial access_token.
Use it to obtain a new access_token
:type refresh_token: str
:param account_id: the selected account ID to use with Basecamp 3
:type account_id: int
"""
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.access_token = access_token
self.refresh_token = refresh_token
self.account_id = account_id

@property
def is_usable(self):
Expand Down Expand Up @@ -113,7 +113,7 @@ class BasecampFileConfig(BasecampConfig):
"""A list of places to look for a configuration file if you do not specify one to the Basecamp3 object."""

def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access_token=None, refresh_token=None,
filepath=None):
account_id=None, filepath=None):
"""
Create a new BasecampConfig with the given values already set. Bare in mind that the fields for this object
remain unchanged until you actually call the read() function. This is in case the file doesn't exist yet and
Expand All @@ -124,7 +124,7 @@ def __init__(self, client_id=None, client_secret=None, redirect_uri=None, access
"""
super(BasecampFileConfig, self).__init__(client_id=client_id, client_secret=client_secret,
redirect_uri=redirect_uri, access_token=access_token,
refresh_token=refresh_token)
refresh_token=refresh_token, account_id=account_id)
self.filepath = filepath

def read(self, filepath=None):
Expand Down Expand Up @@ -210,6 +210,9 @@ def load_from_default_paths(cls):
:rtype basecampy3.config.BasecampFileConfig
:raises basecampy3.exc.NoDefaultConfigurationFound: if none of the files in the list exist
"""
env_defined = os.getenv("BC3_CONFIG_PATH")
if env_defined:
cls.DEFAULT_CONFIG_FILE_LOCATIONS.insert(0, env_defined)
for config_file in cls.DEFAULT_CONFIG_FILE_LOCATIONS:
try:
return cls.from_filepath(config_file)
Expand Down
12 changes: 8 additions & 4 deletions basecampy3/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@
"client_id={0.client_id}&redirect_uri={0.redirect_uri}&client_secret={0.client_secret}" % OAUTH_URL
"""Using a saved refresh token, apply for new access token."""

_home_config = os.path.expanduser(os.path.join("~", ".config"))
# in case the user has a special place for config files:
_default_config_dir = os.getenv("XDG_CONFIG_HOME", _home_config)
DEFAULT_CONFIG_FILE = os.path.join(_default_config_dir, "basecamp.conf")
_user_default_config_dir = os.getenv("BC3_CONFIG_PATH")
if _user_default_config_dir:
DEFAULT_CONFIG_FILE = _user_default_config_dir
else:
_home_config = os.path.expanduser(os.path.join("~", ".config"))
# in case the user has a special place for config files:
_default_config_dir = os.getenv("XDG_CONFIG_HOME", _home_config)
DEFAULT_CONFIG_FILE = os.path.join(_default_config_dir, "basecamp.conf")

DOCK_NAME_CAMPFIRE = 'chat'
DOCK_NAME_MESSAGE_BOARD = 'message_board'
Expand Down
15 changes: 15 additions & 0 deletions basecampy3/endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from .answers import Answers
from .campfire_lines import CampfireLines
from .campfires import Campfires
from .comments import Comments
from .message_boards import MessageBoards
from .message_categories import MessageCategories
from .messages import Messages
from .people import People
from .project_constructions import ProjectConstructions
from .projects import Projects
from .templates import Templates
from .todolist_groups import TodoListGroups
from .todolists import TodoLists
from .todos import Todos
from .todosets import TodoSets
Loading

0 comments on commit f238915

Please # to comment.