diff --git a/docs/datastore-narrative_example.rst b/docs/datastore-narrative_example.rst new file mode 100644 index 000000000000..28110831a8d6 --- /dev/null +++ b/docs/datastore-narrative_example.rst @@ -0,0 +1,382 @@ +Cloud Datastore Narrative / Example Application +=============================================== + +Overview +-------- + +In order to give a better feel for how a Python application might use the +:mod:`gcloud.datastore` API, let's look at building an example application +which stores its data using the API. In order to focus on the API, the +sample application will be built as a set of command-line scripts. + +For our example, let's build an employee expense reporting system. An example +set of interactions might look like the following scenario. + +Returning from a trip to the Bay Area, Sally creates a CSV file, +``expenses-20140901.csv``, containing one row for each line item in her +expense report: + +.. code-block:: none + + "Date","Vendor","Type","Quantity","Price","Memo" + "2014-08-26","United Airlines","Travel",1,425.00,"Airfaire, IAD <-> SFO" + "2014-08-27","Yellow Cab","Travel",32.00,"Taxi to IAD" + ... + +Sally then submits her expense report from the command line using our +:program:`submit_expenses` script (see :ref:`create-expense-report`): + +.. code-block:: bash + + $ submit_expenses create --employee-id=sally --description="Frotz project kickoff, San Jose" expenses-20140901.csv + Processed 15 rows. + Created report: sally/expenses-20140901 + +Sally can list all her submitted expense reports using our +:program:`review_expenses` script (see :ref:`list-expense-reports`): + +.. code-block:: bash + + $ review_expenses list --employee-id=sally + + "Employee ID", "Report ID","Created","Updated","Description","Status","Memo" + "sally","expenses-2013-11-19","2013-12-01","2013-12-01","Onsite Training, Mountain View","Paid","Check #3715" + "sally","expenses-2014-04-19","2014-04-21","2014-04-22","PyCon 2014, Montreal","Paid","Check #3992" + "sally","expenses-2014-09-01","2014-09-04","2014-09-04","Frotz project kickoff, San Jose","pending","" + +Sally can review a submitted expense report using the its ID +(see :ref:`show-expense-report`): + +.. code-block:: bash + + $ review_expenses show sally expenses-20140901 + Employee-ID: sally + Report-ID: expenses-20140901 + Report-Status: pending + Created: 2014-09-04 + Updated: 2014-09-04 + Description: Frotz project kickoff, San Jose + + "Date","Vendor","Type","Quantity","Price","Memo" + "2014-08-26","United Airlines","Travel",1,425.00,"Airfaire, IAD <-> SFO" + "2014-08-27","Yellow Cab","Travel",32.00,"Taxi to IAD" + ... + +While in "pending" status, Sally can edit the CSV and resubmit it +(see :ref:`update-expense-report`): + +.. code-block:: bash + + $ submit_expenses update expenses-20140901.csv + Updated report: sally/expenses-20140901 + Processed 15 rows. + +While it remains in "pending" status, Sally can also delete the report +(see :ref:`delete-expense-report`): + +.. code-block:: bash + + $ submit_expenses delete expenses-20140901 + Deleted report: sally/expenses-20140901 + Removed 15 items. + +Sally's boss, Pat, can review all open expense reports +(see :ref:`list-expense-reports`): + +.. code-block:: bash + + $ review_expenses list --status=pending + + "Employee ID","Report ID","Created","Updated","Description","Status","Memo" + "sally","expenses-2014-09-01","2014-09-04","2014-09-04","Frotz project kickoff, San Jose","pending","" + + +Pat can download Sally's report +(see :ref:`show-expense-report`): + +.. code-block:: bash + + $ review_expenses show sally expenses-20140901 + Report-ID: sally/expenses-20140901 + Report-Status: pending + Employee-ID: sally + Description: Frotz project kickoff, San Jose + + "Date","Vendor","Type","Quantity","Price","Memo" + "2014-08-26","United Airlines","Travel",1,425.00,"Airfaire, IAD <-> SFO" + "2014-08-27","Yellow Cab","Travel",32.00,"Taxi to IAD" + +Pat can approve Sally's expense report +(see :ref:`approve-expense-report`): + +.. code-block:: bash + + $ review_expenses approve --check-number=4093 sally expenses-20140901 + Approved, report: sally/expenses-20140901, check #4093 + +or reject it +(see :ref:`reject-expense-report`): + +.. code-block:: bash + + $ review_expenses reject --reason="Travel not authorized by client" sally expenses-20140901 + Rejected, report: sally/expenses-20140901, reason: Travel not authorized by client + +Connecting to the API Dataset +----------------------------- + +The sample application uses a utility function, :func:`expenses._get_dataset`, +to set up the connection. + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _get_dataset + +Thie function expects three environment variables to be set up, using +your project's +`OAuth2 API credentials `_: + +- :envvar:`GCLOUD_TESTS_DATASET_ID` is your Google API Project ID +- :envvar:`GCLOUD_TESTS_CLIENT_EMAIL` is your Google API email-address +- :envvar:`GCLOUD_TESTS_TESTS_KEY_FILE` is the filesystem path to your + Google API private key. + +.. _create-expense-report: + +Creating a New Expense Report +----------------------------- + +In the sample application, the ``create`` subcommand of the +:program:`submit_expenses` script drives a function, +:func:`expenses.create_report`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: create_report + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.create_report` starts a transaction (line 3) to ensure that +all changes are performed atomically. It then checks that no report exists +already for the given employee ID and report ID, raising an exception if so +(lines 4-5). It then delegates most of the work to the +:func:`expenses._upsert_report` utility function (line 6), finally setting +metadata on the report itself (lines 7-11). + + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _upsert_report + :linenos: + +The :func:`expenses._upsert_report` function: in turn delegates to +:func:`expenses._get_employee`, :func:`expenses._get_report`, and +:func:`expenses._purge_report_items` to ensure that the employee and report +exist, and that the report contains no items (lines 2-4). It then +iterates over the rows from the CSV file, creating an item for each row +(lines 5-13), finally returning the populated report object (line 14). + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _get_employee + :linenos: + +The :func:`expenses._get_employee` function: looks up an employee (lines 2-3). + +.. note:: Employee entities have no "parent" object: they exist at the "top" + level. + +If the employee entity does not exist, and the caller requests it, the +function creates a new employee entity and saves it. + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _get_report + :linenos: + +The :func:`expenses._get_employee` function: looks up an expense report +using an `"ancestor" query +`_ +(lines 2-3). + +.. note:: Each expense report entity is expected to have an employee entity + as its "parent". + +If the expense report entity does not exist, and the caller requests it, the +function creates a new expense report entity and saves it. + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _purge_report_items + :linenos: + +The :func:`expenses._purge_report_items` function: delegates to +:func:`expenses._fetch_report_items` to find expense item entities contained +within the given report (line 4), and deletes them (line 5). It returns +a count of the deleted items. + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _fetch_report_items + :linenos: + +The :func:`expenses._purge_report_items` function: performs an "ancestor" +query (lines 2-3) to find expense item entities contained within a given +expense report. + +.. _update-expense-report: + +Updating an Existing Expense Report +----------------------------------- + +In the sample application, the ``update`` subcommand of the +:program:`submit_expenses` script drives a function, +:func:`expenses.update_report`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: update_report + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.update_report` starts a transaction (line 3) to ensure that +all changes are performed atomically. It then checks that a report *does* +exist already for the given employee ID and report ID, and that it is in +``pending`` status, raising an exception if not (lines 4-5). It then +delegates most of the work to the :func:`expenses._upsert_report` utility +function (line 6), finally updating metadata on the report itself (lines 7-11). + +.. _delete-expense-report: + +Deleting an Existing Expense Report +----------------------------------- + +In the sample application, the ``delete`` subcommand of the +:program:`submit_expenses` script drives a function, +:func:`expenses.delete_report`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: delete_report + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.delete_report` starts a transaction (line 3) to ensure that +all changes are performed atomically. It then checks that a report *does* +exist already for the given employee ID and report ID (lines 4-6), and that +it is in ``pending`` status (lines 7-8), raising an exception if either is +false. + +.. note:: + + The function takes a ``force`` argument: if true, it will delete the + report even if it is not in ``pending`` status. + +The function then delegates to :func:`expenses._purge_report_items` to +delete expense item entities contained in the report (line 9), and then +deletes the report itself (line 10). Finally, it returns a count of the +deleted items. + +.. _list-expense-reports: + +Listing Expense Reports +----------------------- + +In the sample application, the ``list`` subcommand of the +:program:`review_expenses` script drives a function, +:func:`expenses.list_reports`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: list_reports + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.list_reports` creates a :class:`~gcloud.dataset.query.Query` +instance, limited to entities of kind, ``Expense Report`` (line 3), and +applies filtering based on the passed criteria: + +- If ``employee_id`` is passed, it adds an "ancestor" filter to + restrict the results to expense reports contained in the given employee + (lines 4-6). + +- If ``status`` is passed, it adds an "attribute" filter to + restrict the results to expense reports which have that status (lines 7-8). + +.. note:: + + The function does *not* set up a transaction, as it uses only + "read" operations on the API. + +Finally, the function fetches the expense report entities returned by +the query and iterates over them, passing each to +:func:`expenses._report_info` and yielding the mapping it returns. +report (lines 9-10). + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: _report_info + :linenos: + +The :func:`expenses._report_info` utility function uses the expense report +entity's key to determine the report's employee ID (line 3), and its +report ID (line 4). It then uses these values and the entityy's properties +to generate and return a mapping describing the report (lines 5-22). + +.. _show-expense-report: + +Showing an Expense Report +------------------------- + +In the sample application, the ``show`` subcommand of the +:program:`review_expenses` script drives a function, +:func:`expenses.get_report_info`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: get_report_info + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.get_report_info` uses :func:`exenses._get_report` to fetch +the expense report entity for the given employee ID and report ID (line 3), +raising an exeception if the report does not exist (line 4): + +.. note:: + + The function does *not* set up a transaction, as it uses only + "read" operations on the API. + +The function delegates to :func:`expenses._report_info` to get a mapping +describing the report (line 6), and then delegates to +:func:`expenses._fetch_report_items` to retrieve information about the +expense item entities contained in the report (line 7). Finally, the +function returns the mapping. + +.. _approve-expense-report: + +Approving an Expense Report +--------------------------- + +In the sample application, the ``approve`` subcommand of the +:program:`review_expenses` script drives a function, +:func:`expenses.approve_report`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: approve_report + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.approve_report` starts a transaction (line 3) to ensure that +all changes are performed atomically. It then checks that a report *does* +exist already for the given employee ID and report ID, and that it is in +``pending`` status, raising an exception if not (lines 4-5). It then updates +the status and other metadata on the report itself (lines 9-12). + +.. _reject-expense-report: + +Rejecting an Expense Report +--------------------------- + +In the sample application, the ``reject`` subcommand of the +:program:`review_expenses` script drives a function, +:func:`expenses.reject_report`: + +.. literalinclude:: examples/expenses/expenses/__init__.py + :pyobject: reject_report + :linenos: + +After connecting to the dataset via :func:`expenses._get_dataset` (line 2), +:func:`expenses.approve_report` starts a transaction (line 3) to ensure that +all changes are performed atomically. It then checks that a report *does* +exist already for the given employee ID and report ID, and that it is in +``pending`` status, raising an exception if not (lines 4-5). It then updates +the status and other metadata on the report itself (lines 9-12). diff --git a/docs/examples/expenses/expenses-20140901.csv b/docs/examples/expenses/expenses-20140901.csv new file mode 100644 index 000000000000..47ae47fad965 --- /dev/null +++ b/docs/examples/expenses/expenses-20140901.csv @@ -0,0 +1,3 @@ +"Date","Vendor","Type","Quantity","Price","Memo" +"2014-08-26","United Airlines","Travel",1,425.00,"Airfaire, IAD <-> SFO" +"2014-08-27","Yellow Cab","Travel",1,32.00,"Taxi to IAD" diff --git a/docs/examples/expenses/expenses/__init__.py b/docs/examples/expenses/expenses/__init__.py new file mode 100644 index 000000000000..0944c369fcb7 --- /dev/null +++ b/docs/examples/expenses/expenses/__init__.py @@ -0,0 +1,195 @@ +import datetime +import os + +from gcloud import datastore +from gcloud.datastore.key import Key +from gcloud.datastore.entity import Entity +from gcloud.datastore.query import Query + + +class DuplicateReport(Exception): + """Attempt to create a report which already exists.""" + + +class NoSuchReport(Exception): + """Attempt to update / delete a report which does not already exist.""" + + +class BadReportStatus(Exception): + """Attempt to update / delete an already-approved/rejected report.""" + + +def _get_dataset(): + client_email = os.environ['GCLOUD_TESTS_CLIENT_EMAIL'] + private_key_path = os.environ['GCLOUD_TESTS_KEY_FILE'] + dataset_id = os.environ['GCLOUD_TESTS_DATASET_ID'] + return datastore.get_dataset(dataset_id, client_email, private_key_path) + + +def _get_employee(dataset, employee_id, create=True): + key = Key(path=[ + {'kind': 'Employee', 'name': employee_id}, + ]) + employee = dataset.get_entity(key) + if employee is None and create: + employee = dataset.entity('Employee').key(key) + employee.save() + return employee + + +def _get_report(dataset, employee_id, report_id, create=True): + key = Key(path=[ + {'kind': 'Employee', 'name': employee_id}, + {'kind': 'Expense Report', 'name': report_id}, + ]) + report = dataset.get_entity(key) + if report is None and create: + report = dataset.entity('Report').key(key) + report.save() + return report + + +def _fetch_report_items(dataset, report): + query = Query('Expense Item', dataset) + for item in query.ancestor(report.key()).fetch(): + yield item + + +def _report_info(report): + path = report.key().path() + employee_id = path[0]['name'] + report_id = path[1]['name'] + created = report['created'].strftime('%Y-%m-%d') + updated = report['updated'].strftime('%Y-%m-%d') + status = report['status'] + if status == 'paid': + memo = report['check_number'] + elif status == 'rejected': + memo = report['rejected_reason'] + else: + memo = '' + return { + 'employee_id': employee_id, + 'report_id': report_id, + 'created': created, + 'updated': updated, + 'status': status, + 'description': report.get('description', ''), + 'memo': memo, + } + + +def _purge_report_items(dataset, report): + # Delete any existing items belonging to report + count = 0 + for item in _fetch_report_items(dataset, report): + item.delete() + count += 1 + return count + + +def _upsert_report(dataset, employee_id, report_id, rows): + _get_employee(dataset, employee_id) # force existence + report = _get_report(dataset, employee_id, report_id) + _purge_report_items(dataset, report) + # Add items based on rows. + report_path = report.key().path() + for i, row in enumerate(rows): + path = report_path + [{'kind': 'Expense Item', 'id': i + 1}] + key = Key(path=path) + item = Entity(dataset, 'Expense Item').key(key) + for k, v in row.items(): + item[k] = v + item.save() + return report + + +def list_reports(employee_id=None, status=None): + dataset = _get_dataset() + query = Query('Expense Report', dataset) + if employee_id is not None: + key = Key(path=[{'kind': 'Employee', 'name': employee_id}]) + query = query.ancestor(key) + if status is not None: + query = query.filter('status =', status) + for report in query.fetch(): + yield _report_info(report) + + +def get_report_info(employee_id, report_id): + dataset = _get_dataset() + report = _get_report(dataset, employee_id, report_id, False) + if report is None: + raise NoSuchReport() + info = _report_info(report) + info['items'] = [dict(x) for x in _fetch_report_items(dataset, report)] + return info + + +def create_report(employee_id, report_id, rows, description): + dataset = _get_dataset() + with dataset.transaction(): + if _get_report(dataset, employee_id, report_id, False) is not None: + raise DuplicateReport() + report = _upsert_report(dataset, employee_id, report_id, rows) + report['status'] = 'pending' + if description is not None: + report['description'] = description + report['created'] = report['updated'] = datetime.datetime.utcnow() + report.save() + + +def update_report(employee_id, report_id, rows, description): + dataset = _get_dataset() + with dataset.transaction(): + report = _get_report(dataset, employee_id, report_id, False) + if report is None: + raise NoSuchReport() + if report['status'] != 'pending': + raise BadReportStatus(report['status']) + _upsert_report(dataset, employee_id, report_id, rows) + if description is not None: + report['description'] = description + report['updated'] = datetime.datetime.utcnow() + report.save() + + +def delete_report(employee_id, report_id, force): + dataset = _get_dataset() + with dataset.transaction(): + report = _get_report(dataset, employee_id, report_id, False) + if report is None: + raise NoSuchReport() + if report['status'] != 'pending' and not force: + raise BadReportStatus(report['status']) + count = _purge_report_items(dataset, report) + report.delete() + return count + + +def approve_report(employee_id, report_id, check_number): + dataset = _get_dataset() + with dataset.transaction(): + report = _get_report(dataset, employee_id, report_id, False) + if report is None: + raise NoSuchReport() + if report['status'] != 'pending': + raise BadReportStatus(report['status']) + report['updated'] = datetime.datetime.utcnow() + report['status'] = 'paid' + report['check_number'] = check_number + report.save() + + +def reject_report(employee_id, report_id, reason): + dataset = _get_dataset() + with dataset.transaction(): + report = _get_report(dataset, employee_id, report_id, False) + if report is None: + raise NoSuchReport() + if report['status'] != 'pending': + raise BadReportStatus(report['status']) + report['updated'] = datetime.datetime.utcnow() + report['status'] = 'rejected' + report['reason'] = reason + report.save() diff --git a/docs/examples/expenses/expenses/scripts/__init__.py b/docs/examples/expenses/expenses/scripts/__init__.py new file mode 100644 index 000000000000..5bb534f795ae --- /dev/null +++ b/docs/examples/expenses/expenses/scripts/__init__.py @@ -0,0 +1 @@ +# package diff --git a/docs/examples/expenses/expenses/scripts/review_expenses.py b/docs/examples/expenses/expenses/scripts/review_expenses.py new file mode 100644 index 000000000000..cf6366d452d2 --- /dev/null +++ b/docs/examples/expenses/expenses/scripts/review_expenses.py @@ -0,0 +1,296 @@ +import csv +import optparse +import os +import textwrap +import sys + +from .. import NoSuchReport +from .. import approve_report +from .. import get_report_info +from .. import list_reports +from .. import reject_report + + +class InvalidCommandLine(ValueError): + pass + + +class NotACommand(object): + def __init__(self, bogus): + self.bogus = bogus + + def __call__(self): + raise InvalidCommandLine('Not a command: %s' % self.bogus) + + +def _get_csv(args): + try: + csv_file, = args + except: + raise InvalidCommandLine('Specify one CSV file') + csv_file = os.path.abspath(os.path.normpath(csv_file)) + if not os.path.exists(csv_file): + raise InvalidCommandLine('Invalid CSV file: %s' % csv_file) + with open(csv_file) as f: + return csv_file, list(csv.DictReader(f)) + + +class ListReports(object): + """List expense reports according to specified criteria. + """ + def __init__(self, submitter, *args): + self.submitter = submitter + args = list(args) + parser = optparse.OptionParser( + usage="%prog [OPTIONS]") + + parser.add_option( + '-e', '--employee-id', + action='store', + dest='employee_id', + default=None, + help="ID of the employee whose expense reports to list") + + parser.add_option( + '-s', '--status', + action='store', + dest='status', + default=None, + help="Status of expense reports to list") + + options, args = parser.parse_args(args) + self.employee_id = options.employee_id + self.status = options.status + + def __call__(self): + _cols = [ + ('employee_id', 'Employee ID'), + ('report_id', 'Report ID'), + ('created', 'Created'), + ('updated', 'Updated'), + ('description', 'Description'), + ('status', 'Status'), + ('memo', 'Memo'), + ] + writer = csv.writer(sys.stdout) + writer.writerow([x[1] for x in _cols]) + for report in list_reports(self.employee_id, self.status): + writer.writerow([report[x[0]] for x in _cols]) + + +class ShowReport(object): + """Dump the contents of a given expense report. + """ + def __init__(self, submitter, *args): + self.submitter = submitter + args = list(args) + parser = optparse.OptionParser( + usage="%prog [OPTIONS] EMPLOYEE_ID REPORT_ID") + + _, args = parser.parse_args(args) + try: + self.employee_id, self.report_id, = args + except: + raise InvalidCommandLine('Specify employee ID, report ID') + + def __call__(self): + _cols = ['Date', 'Vendor', 'Type', 'Quantity', 'Price', 'Memo'] + try: + info = get_report_info(self.employee_id, self.report_id) + except NoSuchReport: + self.submitter.blather("No such report: %s/%s" + % (self.employee_id, self.report_id)) + else: + self.submitter.blather("Employee-ID: %s" % info['employee_id']) + self.submitter.blather("Report-ID: %s" % info['report_id']) + self.submitter.blather("Report-Status: %s" % info['status']) + self.submitter.blather("Created: %s" % info['created']) + self.submitter.blather("Updated: %s" % info['updated']) + self.submitter.blather("Description: %s" % info['description']) + self.submitter.blather("") + writer = csv.writer(sys.stdout) + writer.writerow([x for x in _cols]) + for item in info['items']: + writer.writerow([item[x] for x in _cols]) + + +class ApproveReport(object): + """Approve a given expense report. + """ + def __init__(self, submitter, *args): + self.submitter = submitter + args = list(args) + parser = optparse.OptionParser( + usage="%prog [OPTIONS] EMPLOYEE_ID REPORT_ID") + + parser.add_option( + '-c', '--check-number', + action='store', + dest='check_number', + default='', + help="Check number issued to pay the expense report") + + options, args = parser.parse_args(args) + try: + self.employee_id, self.report_id, = args + except: + raise InvalidCommandLine('Specify employee ID, report ID') + self.check_number = options.check_number + + def __call__(self): + approve_report(self.employee_id, self.report_id, self.check_number) + memo = ('' if self.check_number is None else + ', check #%s' % self.check_number) + self.submitter.blather("Approved report: %s/%s%s" % + (self.employee_id, self.report_id, memo)) + + +class RejectReport(object): + """Reject a given expense report. + """ + def __init__(self, submitter, *args): + self.submitter = submitter + args = list(args) + parser = optparse.OptionParser( + usage="%prog [OPTIONS] EMPLOYEE_ID REPORT_ID") + + parser.add_option( + '-r', '--reason', + action='store', + dest='reason', + default=None, + help="Reason for rejecting the expense report") + + options, args = parser.parse_args(args) + try: + self.employee_id, self.report_id, = args + except: + raise InvalidCommandLine('Specify employee ID, report ID') + self.reason = options.reason + + def __call__(self): + reject_report(self.employee_id, self.report_id, self.reason) + memo = ('' if self.reason is None else ', reason: %s' % self.reason) + self.submitter.blather("Rejected report: %s/%s%s" % + (self.employee_id, self.report_id, memo)) + + +_COMMANDS = { + 'list': ListReports, + 'show': ShowReport, + 'approve': ApproveReport, + 'reject': RejectReport, +} + + +def get_description(command): + klass = _COMMANDS[command] + doc = getattr(klass, '__doc__', '') + if doc is None: + return '' + return ' '.join([x.lstrip() for x in doc.split('\n')]) + + +class ReviewExpenses(object): + """ Driver for the :command:`review_expenses` command-line script. + """ + def __init__(self, argv=None, logger=None): + self.commands = [] + if logger is None: + logger = self._print + self.logger = logger + self.parse_arguments(argv) + + def parse_arguments(self, argv=None): + """ Parse subcommands and their options from an argv list. + """ + # Global options (not bound to sub-command) + mine = [] + queue = [(None, mine)] + + def _recordCommand(arg): + if arg is not None: + queue.append((arg, [])) + + for arg in argv: + if arg in _COMMANDS: + _recordCommand(arg) + else: + queue[-1][1].append(arg) + + _recordCommand(None) + + usage = ("%prog [GLOBAL_OPTIONS] " + "[command [COMMAND_OPTIONS]* [COMMAND_ARGS]]") + parser = optparse.OptionParser(usage=usage) + + parser.add_option( + '-s', '--help-commands', + action='store_true', + dest='help_commands', + help="Show command help") + + parser.add_option( + '-q', '--quiet', + action='store_const', const=0, + dest='verbose', + help="Run quietly") + + parser.add_option( + '-v', '--verbose', + action='count', + dest='verbose', + default=1, + help="Increase verbosity") + + options, args = parser.parse_args(mine) + + self.options = options + + for arg in args: + self.commands.append(NotACommand(arg)) + options.help_commands = True + + if options.help_commands: + keys = sorted(_COMMANDS.keys()) + self.error('Valid commands are:') + for x in keys: + self.error(' %s' % x) + doc = get_description(x) + if doc: + self.error(textwrap.fill(doc, + initial_indent=' ', + subsequent_indent=' ')) + return + + for command_name, args in queue: + if command_name is not None: + command = _COMMANDS[command_name](self, *args) + self.commands.append(command) + + def __call__(self): + """ Invoke sub-commands parsed by :meth:`parse_arguments`. + """ + if not self.commands: + raise InvalidCommandLine('No commands specified') + + for command in self.commands: + command() + + def _print(self, text): # pragma NO COVERAGE + sys.stdout.write('%s/n' % text) + + def error(self, text): + self.logger(text) + + def blather(self, text, min_level=1): + if self.options.verbose >= min_level: + self.logger(text) + + +def main(argv=sys.argv[1:]): + try: + ReviewExpenses(argv)() + except InvalidCommandLine as e: # pragma NO COVERAGE + sys.stdout.write('%s\n' % (str(e))) + sys.exit(1) diff --git a/docs/examples/expenses/expenses/scripts/submit_expenses.py b/docs/examples/expenses/expenses/scripts/submit_expenses.py new file mode 100644 index 000000000000..e1d74dd9b1c5 --- /dev/null +++ b/docs/examples/expenses/expenses/scripts/submit_expenses.py @@ -0,0 +1,282 @@ +import csv +import optparse +import os +import textwrap +import sys + + +from .. import BadReportStatus +from .. import DuplicateReport +from .. import NoSuchReport +from .. import create_report +from .. import delete_report +from .. import update_report + + +class InvalidCommandLine(ValueError): + pass + + +class NotACommand(object): + def __init__(self, bogus): + self.bogus = bogus + + def __call__(self): + raise InvalidCommandLine('Not a command: %s' % self.bogus) + + +def _get_csv(args): + try: + csv_file, = args + except: + raise InvalidCommandLine('Specify one CSV file') + csv_file = os.path.abspath(os.path.normpath(csv_file)) + if not os.path.exists(csv_file): + raise InvalidCommandLine('Invalid CSV file: %s' % csv_file) + with open(csv_file) as f: + return csv_file, list(csv.DictReader(f)) + + +class _Command(object): + """Base class for create / update commands. + """ + def __init__(self, submitter, *args): + self.submitter = submitter + args = list(args) + parser = optparse.OptionParser( + usage="%prog [OPTIONS] CSV_FILE") + + parser.add_option( + '-e', '--employee-id', + action='store', + dest='employee_id', + default=os.getlogin(), + help="ID of employee submitting the expense report") + + parser.add_option( + '-r', '--report-id', + action='store', + dest='report_id', + default=None, + help="ID of the expense report to update") + + parser.add_option( + '-d', '--description', + action='store', + dest='description', + default='', + help="Short description of the expense report") + + options, args = parser.parse_args(args) + self.employee_id = options.employee_id + self.report_id = options.report_id + self.description = options.description + self.filename, self.rows = _get_csv(args) + if self.report_id is None: + fn = os.path.basename(self.filename) + base, _ = os.path.splitext(fn) + self.report_id = base + + +class CreateReport(_Command): + """Create a new expense report from a CSV file. + """ + def __call__(self): + try: + create_report(self.employee_id, self.report_id, self.rows, + self.description) + except DuplicateReport: + self.submitter.blather("Report already exists: %s/%s" + % (self.employee_id, self.report_id)) + else: + self.submitter.blather("Created report: %s/%s" + % (self.employee_id, self.report_id)) + self.submitter.blather("Processed %d rows." % len(self.rows)) + + +class UpdateReport(_Command): + """Update an existing expense report from a CSV file. + """ + def __call__(self): + try: + update_report(self.employee_id, self.report_id, self.rows, + self.description) + except NoSuchReport: + self.submitter.blather("No such report: %s/%s" + % (self.employee_id, self.report_id)) + except BadReportStatus as e: + self.submitter.blather("Invalid report status: %s/%s, %s" + % (self.employee_id, self.report_id, + str(e))) + else: + self.submitter.blather("Updated report: %s/%s" + % (self.employee_id, self.report_id)) + self.submitter.blather("Processed %d rows." % len(self.rows)) + + +class DeleteReport(object): + """Delete an existing expense report. + """ + def __init__(self, submitter, *args): + self.submitter = submitter + args = list(args) + parser = optparse.OptionParser( + usage="%prog [OPTIONS] REPORT_ID") + + parser.add_option( + '-e', '--employee-id', + action='store', + dest='employee_id', + default=os.getlogin(), + help="ID of employee owning the expense report") + + parser.add_option( + '-f', '--force', + action='store_true', + dest='force', + default=False, + help="Delete report even if not in 'pending' status") + + options, args = parser.parse_args(args) + try: + self.report_id, = args + except: + raise InvalidCommandLine('Specify one report ID') + + self.employee_id = options.employee_id + self.force = options.force + + def __call__(self): + try: + count = delete_report(self.employee_id, self.report_id, self.force) + except NoSuchReport: + self.submitter.blather("No such report: %s/%s" + % (self.employee_id, self.report_id)) + except BadReportStatus as e: + self.submitter.blather("Invalid report status: %s/%s, %s" + % (self.employee_id, self.report_id, + str(e))) + else: + self.submitter.blather("Deleted report: %s/%s" + % (self.employee_id, self.report_id)) + self.submitter.blather("Removed %d items." % count) + + +_COMMANDS = { + 'create': CreateReport, + 'update': UpdateReport, + 'delete': DeleteReport, +} + + +def get_description(command): + klass = _COMMANDS[command] + doc = getattr(klass, '__doc__', '') + if doc is None: + return '' + return ' '.join([x.lstrip() for x in doc.split('\n')]) + + +class SubmitExpenses(object): + """ Driver for the :command:`submit_expenses` command-line script. + """ + def __init__(self, argv=None, logger=None): + self.commands = [] + if logger is None: + logger = self._print + self.logger = logger + self.parse_arguments(argv) + + def parse_arguments(self, argv=None): + """ Parse subcommands and their options from an argv list. + """ + # Global options (not bound to sub-command) + mine = [] + queue = [(None, mine)] + + def _recordCommand(arg): + if arg is not None: + queue.append((arg, [])) + + for arg in argv: + if arg in _COMMANDS: + _recordCommand(arg) + else: + queue[-1][1].append(arg) + + _recordCommand(None) + + usage = ("%prog [GLOBAL_OPTIONS] " + "[command [COMMAND_OPTIONS]* [COMMAND_ARGS]]") + parser = optparse.OptionParser(usage=usage) + + parser.add_option( + '-s', '--help-commands', + action='store_true', + dest='help_commands', + help="Show command help") + + parser.add_option( + '-q', '--quiet', + action='store_const', const=0, + dest='verbose', + help="Run quietly") + + parser.add_option( + '-v', '--verbose', + action='count', + dest='verbose', + default=1, + help="Increase verbosity") + + options, args = parser.parse_args(mine) + + self.options = options + + for arg in args: + self.commands.append(NotACommand(arg)) + options.help_commands = True + + if options.help_commands: + keys = sorted(_COMMANDS.keys()) + self.error('Valid commands are:') + for x in keys: + self.error(' %s' % x) + doc = get_description(x) + if doc: + self.error(textwrap.fill(doc, + initial_indent=' ', + subsequent_indent=' ')) + return + + for command_name, args in queue: + if command_name is not None: + command = _COMMANDS[command_name](self, *args) + self.commands.append(command) + + def __call__(self): + """ Invoke sub-commands parsed by :meth:`parse_arguments`. + """ + if not self.commands: + raise InvalidCommandLine('No commands specified') + + for command in self.commands: + command() + + def _print(self, text): # pragma NO COVERAGE + sys.stdout.write('%s\n' % text) + + def error(self, text): + self.logger(text) + + def blather(self, text, min_level=1): + if self.options.verbose >= min_level: + self.logger(text) + + +def main(argv=sys.argv[1:]): + try: + SubmitExpenses(argv)() + except InvalidCommandLine as e: # pragma NO COVERAGE + sys.stdout.write('%s\n' % str(e)) + sys.exit(1) diff --git a/docs/examples/expenses/setup.py b/docs/examples/expenses/setup.py new file mode 100644 index 000000000000..c8a7bcb43cee --- /dev/null +++ b/docs/examples/expenses/setup.py @@ -0,0 +1,19 @@ +from setuptools import find_packages +from setuptools import setup + +setup( + name='expenses', + version='0.1', + description='Expense report upload (example for gcloud.datastore)', + packages=find_packages(), + include_package_data=True, + install_requires=[ + 'gcloud', + ], + entry_points={ + 'console_scripts': [ + 'submit_expenses = expenses.scripts.submit_expenses:main', + 'review_expenses = expenses.scripts.review_expenses:main', + ], + }, +) diff --git a/docs/index.rst b/docs/index.rst index d65017fe6636..f7a8ab129a34 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ datastore-api datastore-getting-started datastore-quickstart + datastore-narrative_example getting-started storage-api storage-getting-started diff --git a/run_pylint.py b/run_pylint.py index 21560f318112..55159f6f5af5 100644 --- a/run_pylint.py +++ b/run_pylint.py @@ -32,8 +32,10 @@ def is_production_filename(filename): :rtype: `bool` :returns: Boolean indicating production status. """ - return not ('demo' in filename or 'test' in filename - or filename.startswith('regression')) + return not ('demo' in filename or + 'test' in filename or + 'docs' in filename or + filename.startswith('regression')) def get_python_files():