From 59db85fba485461b8f0dbae16d8ea531a642cc80 Mon Sep 17 00:00:00 2001 From: Sharoon Thomas Date: Tue, 7 May 2024 17:26:00 +0200 Subject: [PATCH] Format and upgrade codebase --- .github/workflows/ci.yml | 1 - docs/conf.py | 135 +++++++------- examples/contacts-and-addresses.py | 116 ------------ examples/create-products.py | 81 --------- examples/create-sale-order.py | 280 ----------------------------- examples/fulfil_curlify.py | 35 ---- examples/sale.py | 80 --------- fulfil_client/__init__.py | 11 +- fulfil_client/client.py | 269 +++++++++++++-------------- fulfil_client/contrib/kombu.py | 5 +- fulfil_client/contrib/mail.py | 36 ++-- fulfil_client/contrib/mocking.py | 11 +- fulfil_client/exceptions.py | 7 +- fulfil_client/model.py | 147 +++++++-------- fulfil_client/oauth.py | 21 +-- fulfil_client/serialization.py | 1 - fulfil_client/signals.py | 38 ++-- setup.py | 60 +++---- tests/conftest.py | 10 +- tests/test_data_structures.py | 108 +++++------ tests/test_fulfil_client.py | 101 ++++------- tests/test_mocking.py | 50 +++--- travis_pypi_setup.py | 122 ------------- 23 files changed, 474 insertions(+), 1251 deletions(-) delete mode 100644 examples/contacts-and-addresses.py delete mode 100644 examples/create-products.py delete mode 100644 examples/create-sale-order.py delete mode 100644 examples/fulfil_curlify.py delete mode 100644 examples/sale.py delete mode 100755 travis_pypi_setup.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f17f1..d11c8a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: psf/black@stable - uses: chartboost/ruff-action@v1 build: strategy: diff --git a/docs/conf.py b/docs/conf.py index d6f6db9..9918554 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # directory, add these directories to sys.path here. If the directory is # relative to the documentation root, use os.path.abspath to make it # absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # Get the project root dir, which is the parent dir of this cwd = os.getcwd() @@ -36,27 +36,27 @@ # -- General configuration --------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'fulfil_client' -copyright = u'2016, Fulfil.IO Inc.' +project = "fulfil_client" +copyright = "2016, Fulfil.IO Inc." # The version info for the project you're documenting, acts as replacement # for |version| and |release|, also used in various other places throughout @@ -69,126 +69,126 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to # some non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built # documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a # theme further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as # html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the # top of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon # of the docs. This file should be a Windows icon file (.ico) being # 16x16 or 32x32 pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) # here, relative to this directory. They are copied after the builtin # static files, so a file named "default.css" will overwrite the builtin # "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names # to template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. # Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. # Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages # will contain a tag referring to it. The value of this option # must be the base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'fulfil_clientdoc' +htmlhelp_basename = "fulfil_clientdoc" # -- Options for LaTeX output ------------------------------------------ @@ -196,10 +196,8 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. #'preamble': '', } @@ -208,30 +206,34 @@ # (source start file, target name, title, author, documentclass # [howto/manual]). latex_documents = [ - ('index', 'fulfil_client.tex', - u'fulfil_client Documentation', - u'Fulfil.IO Inc.', 'manual'), + ( + "index", + "fulfil_client.tex", + "fulfil_client Documentation", + "Fulfil.IO Inc.", + "manual", + ), ] # The name of an image file (relative to this directory) to place at # the top of the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings # are parts, not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output ------------------------------------ @@ -239,13 +241,11 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'fulfil_client', - u'fulfil_client Documentation', - [u'Fulfil.IO Inc.'], 1) + ("index", "fulfil_client", "fulfil_client Documentation", ["Fulfil.IO Inc."], 1) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ---------------------------------------- @@ -254,22 +254,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'fulfil_client', - u'fulfil_client Documentation', - u'Fulfil.IO Inc.', - 'fulfil_client', - 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "fulfil_client", + "fulfil_client Documentation", + "Fulfil.IO Inc.", + "fulfil_client", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/examples/contacts-and-addresses.py b/examples/contacts-and-addresses.py deleted file mode 100644 index ae2d37e..0000000 --- a/examples/contacts-and-addresses.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from fulfil_client import Client -client = Client('', '') - - -# ================= -# Creating Contacts -# ================= - -Contact = client.model('party.party') -contact, = Contact.create([{'name': 'Jon Doe'}]) - -# You can create multiple contacts in one request too ;-) -contacts = Contact.create([ - { - 'name': 'Jon Doe' - }, { - 'name': 'Matt Bower' - }, { - 'name': 'Joe Blow' - } -]) - -# ================ -# Creating Address -# ================ - -# Note: You need a contact id first to create an address -# -# Add an address to the contact created above -Address = client.model('party.address') -address, = Address.create([{ - 'party': contact['id'], - 'name': 'Jone Doe Apartment', - 'street': '9805 Kaiden Grove', - 'city': 'New Leland', - 'zip': '57726', -}]) - -# Address with country and subdivision - you first need to fetch the -# id of country and subdivision. -Country = client.model('country.country') -Subdivision = client.model('country.subdivision') - -country_usa, = Country.find([('code', '=', 'US')]) -state_california, = Subdivision.find([('code', '=', 'US-CA')]) - -address, = Address.create([{ - 'party': contact['id'], - 'name': 'Jone Doe Apartment', - 'street': '9805 Kaiden Grove', - 'city': 'New Leland', - 'zip': '57726', - 'country': country_usa['id'], - 'subdivision': state_california['id'], -}]) - - -# =========================== -# Creating Contact Mechanism -# =========================== - -# Creating a phone number for contact -ContactMechanism = client.model('party.contact_mechanism') - -phone, = ContactMechanism.create([{ - 'party': contact['id'], - 'type': 'phone', - 'value': '1321322143', -}]) - -# Creating an email address for contact -email, = ContactMechanism.create([{ - 'party': contact['id'], - 'type': 'email', - 'value': 'hola@jondoe@example.com', -}]) - - -# ============================ -# Creating Contacts (Advanced) -# ============================ - -# Creating a contact with address and contact mechanisms -contact, = Contact.create([{ - 'name': 'Jon Doe', - 'addresses': [('create', [{ - 'name': 'Jone Doe Apartment', - 'street': '9805 Kaiden Grove', - 'city': 'New Leland', - 'zip': '57726', - 'country': country_usa['id'], - 'subdivision': state_california['id'] - }])], - 'contact_mechanisms': [('create', [{ - 'type': 'phone', - 'value': '243243234' - }, { - 'email': 'email', - 'value': 'hello@jondoe.com' - }])] -}]) - - -# =================== -# Searching a Contact -# =================== - - -# Search contact by name -print Contact.find([('name', '=', 'Jon Doe')]) - -# Get a contact by ID -print Contact.get(contact['id']) diff --git a/examples/create-products.py b/examples/create-products.py deleted file mode 100644 index 4d25f5c..0000000 --- a/examples/create-products.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from decimal import Decimal - -from fulfil_client import Client -client = Client('', '') - - -# ========================== -# Creating Product Template -# ========================== - -Template = client.model('product.template') - -iphone, = Template.create([{ - 'name': 'iPhone', - 'account_category': True, -}]) - -# ================= -# Creating Products -# ================= - -Product = client.model('product.product') - -iphone6, = Product.create([{ - 'template': iphone['id'], - 'variant_name': 'iPhone 6', - 'code': 'IPHONE-6', - 'list_price': Decimal('699'), - 'cost_price': Decimal('599'), -}]) - -# Another variation -iphone6s, = Product.create([{ - 'template': iphone['id'], - 'variant_name': 'iPhone 6S', - 'code': 'IPHONE-6S', - 'list_price': Decimal('899'), - 'cost_price': Decimal('699'), -}]) - - -# ============================ -# Creating Products (Advanced) -# ============================ - -# Create template and products in single call! -print Template.create([{ - 'name': 'iPhone', - 'account_category': True, - 'products': [('create', [{ - 'variant_name': 'iPhone 6', - 'code': 'IPHONE-6', - 'list_price': Decimal('699'), - 'cost_price': Decimal('599'), - - }, { - 'variant_name': 'iPhone 6S', - 'code': 'IPHONE-6S', - 'list_price': Decimal('899'), - 'cost_price': Decimal('699'), - }])] -}]) - - -# ====================== -# Searching for Products -# ====================== - -# Search by SKU(exact match) -print Product.find([('code', '=', 'IPHONE-6')]) - -# Search by SKU(pattern match) -print Product.find([('code', 'ilike', '%IPHONE%')]) - -# Search by name(pattern match, case insensitive) -print Product.find([('name', 'ilike', '%Phone%')]) - -# Get a product by ID -print Product.get(iphone6s['id']) diff --git a/examples/create-sale-order.py b/examples/create-sale-order.py deleted file mode 100644 index 6695716..0000000 --- a/examples/create-sale-order.py +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -This is a complete example where you have to push an order to Fulfil.IO. The -steps are: - - 1. Fetch inventory for the products that have been sold - 2. Create new customer, address - 3. Process the order. -""" -from datetime import date -from decimal import Decimal - -from fulfil_client import Client -client = Client('', '') - - -def get_warehouses(): - """ - Return the warehouses in the system - """ - StockLocation = client.model('stock.location') - return StockLocation.find( - [('type', '=', 'warehouse')], # filter just warehouses - fields=['code', 'name'] # Get the code and name fields - ) - - -def get_product_inventory(product_id, warehouse_ids): - """ - Return the product inventory in each location. The returned response - will look like:: - - { - 12: { // Product ID - 4: { // Location ID - 'quantity_on_hand': 12.0, - 'quantity_available': 8.0 - }, - 5: { // Location ID - 'quantity_on_hand': 8.0, - 'quantity_available': 8.0 - }, - }, - 126: { // Product ID - 4: { // Location ID - 'quantity_on_hand': 16.0, - 'quantity_available': 15.0 - }, - 5: { // Location ID - 'quantity_on_hand': 9.0, - 'quantity_available': 8.0 - }, - } - } - - Read more: - http://docs.fulfiliorestapi.apiary.io/#reference/product/product-inventory - """ - Product = client.model('product.product') - - return Product.get_product_inventory( - [product_id], warehouse_ids - )[product_id] - - -def get_customer(code): - """ - Fetch a customer with the code. - Returns None if the customer is not found. - """ - Party = client.model('party.party') - results = Party.find([('code', '=', code)]) - if results: - return results[0]['id'] - - -def get_address(customer_id, data): - """ - Easier to fetch the addresses of customer and then check one by one. - - You can get fancy by using some validation mechanism too - """ - Address = client.model('party.address') - - addresses = Address.find( - [('party', '=', customer_id)], - fields=[ - 'name', 'street', 'street_bis', 'city', 'zip', - 'subdivision.code', 'country.code' - ] - ) - for address in addresses: - if ( - address['name'] == data['name'] and - address['street'] == data['street'] and - address['street_bis'] == data['street_bis'] and - address['city'] == data['city'] and - address['zip'] == data['zip'] and - address['subdivision.code'].endswith(data['state']) and - address['country.code'] == data['country']): - return address['id'] - - -def create_address(customer_id, data): - """ - Create an address and return the id - """ - Address = client.model('party.address') - Country = client.model('country.country') - Subdivision = client.model('country.subdivision') - - country, = Country.find([('code', '=', data['country'])]) - state, = Subdivision.find([ - ('code', 'ilike', '%-' + data['state']), # state codes are US-CA, IN-KL - ('country', '=', country['id']) - ]) - - address, = Address.create([{ - 'party': customer_id, - 'name': data['name'], - 'street': data['street'], - 'street_bis': data['street_bis'], - 'city': data['city'], - 'zip': data['zip'], - 'country': country['id'], - 'subdivision': state['id'], - }]) - return address['id'] - - -def create_customer(name, email, phone): - """ - Create a customer with the name. - Then attach the email and phone as contact methods - """ - Party = client.model('party.party') - ContactMechanism = client.model('party.contact_mechanism') - - party, = Party.create([{'name': name}]) - - # Bulk create the email and phone - ContactMechanism.create([ - {'type': 'email', 'value': email, 'party': party}, - {'type': 'phone', 'value': phone, 'party': party}, - ]) - - return party - - -def get_product(code): - """ - Given a product code/sku return the product id - """ - Product = client.model('product.product') - return Product.find( - [('code', '=', code)], # Filter - fields=['code', 'variant_name', 'cost_price'] - )[0] - - -def create_order(order): - """ - Create an order on fulfil from order_details. - See the calling function below for an example of the order_details - """ - SaleOrder = client.model('sale.sale') - SaleOrderLine = client.model('sale.line') - - # Check if customer exists, if not create one - customer_id = get_customer(order['customer']['code']) - if not customer_id: - customer_id = create_customer( - order['customer']['name'], - order['customer']['email'], - order['customer']['phone'], - ) - - # No check if there is a matching address - invoice_address = get_address( - customer_id, - order['invoice_address'] - ) - if not invoice_address: - invoice_address = create_address( - customer_id, - order['invoice_address'] - ) - - # See if the shipping address exists, if not create it - shipment_address = get_address( - customer_id, - order['shipment_address'] - ) - if not shipment_address: - shipment_address = create_address( - customer_id, - order['shipment_address'] - ) - - sale_order_id, = SaleOrder.create([{ - 'reference': order['number'], - 'sale_date': order['date'], - 'party': customer_id, - 'invoice_address': invoice_address, - 'shipment_address': shipment_address, - }]) - - # fetch inventory of all the products before we create lines - warehouses = get_warehouses() - warehouse_ids = [warehouse['id'] for warehouse in warehouses] - - lines = [] - for item in order['items']: - # get the product. We assume ti already exists. - product = get_product(item['product']) - - # find the first location that has inventory - product_inventory = get_product_inventory(product, warehouse_ids) - for location, quantities in product_inventory.items(): - if quantities['quantity_available'] >= item['quantity']: - break - - lines.append({ - 'sale': sale_order_id, - 'product': product, - 'quantity': item['quantity'], - 'unit_price': item['unit_price'], - 'warehouse': location, - }) - - SaleOrderLine.create(lines) - - SaleOrder.quote([sale_order_id]) - SaleOrder.confirm([sale_order_id]) - - -if __name__ == '__main__': - create_order({ - 'customer': { - 'code': 'A1234', - 'name': 'Sharoon Thomas', - 'email': 'st@fulfil.io', - 'phone': '650-999-9999', - }, - 'number': 'SO-12345', # an order number - 'date': date.today(), # An order date - 'invoice_address': { - 'name': 'Sharoon Thomas', - 'street': '444 Castro St.', - 'street2': 'STE 1200', - 'city': 'Mountain View', - 'zip': '94040', - 'state': 'CA', - 'country': 'US', - }, - 'shipment_address': { - 'name': 'Office Manager', - 'street': '444 Castro St.', - 'street2': 'STE 1200', - 'city': 'Mountain View', - 'zip': '94040', - 'state': 'CA', - 'country': 'US', - }, - 'items': [ - { - 'product': 'P123', - 'quantity': 2, - 'unit_price': Decimal('99'), - 'description': 'P123 is a fabulous product', - }, - { - 'product': 'P456', - 'quantity': 1, - 'unit_price': Decimal('100'), - 'description': 'Yet another amazing product', - }, - ] - }) diff --git a/examples/fulfil_curlify.py b/examples/fulfil_curlify.py deleted file mode 100644 index ad64389..0000000 --- a/examples/fulfil_curlify.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Translate fulfil requests to curl. - -Need to have the following installed - -pip install curlify blinker -""" -import os -import curlify -from fulfil_client import Client -from fulfil_client.signals import response_received, signals_available - -fulfil = Client(os.environ['FULFIL_SUBDOMAIN'], os.environ['FULFIL_API_KEY']) - -print("Signal Available?:", signals_available) - -Product = fulfil.model('product.product') - -products = Product.find([]) - -@response_received.connect -def curlify_response(response): - print('=' * 80) - print(curlify.to_curl(response.request)) - print('=' * 80) - print(response.content) - print('=' * 80) - - -print Product.get_next_available_date( - products[0]['id'], - 1, - 4, - True -) diff --git a/examples/sale.py b/examples/sale.py deleted file mode 100644 index aff1f4e..0000000 --- a/examples/sale.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from fulfil_client import Client -client = Client('', '') - - -# ============= -# Creating Sale -# ============= - -# Sale requires customer(contact) and address id. -Contact = client.model('party.party') -Sale = client.model('sale.sale') - -# Get the contact first -contacts = Contact.find([('name', 'ilike', '%Jon%')]) -contact, = Contact.get(contacts[0]['id']) - -sale, = Sale.create([{ - 'party': contact['id'], - 'shipment_address': contact['addresses'][0], - 'invoice_address': contact['addresses'][0], -}]) - -# =========================== -# Adding items(line) to Sale -# =========================== - -Product = client.model('product.product') -Line = client.model('sale.line') - -products = Product.find([('code', '=', 'IPHONE-6')]) -iphone6, = Product.get(products[0]['id']) - -products = Product.find([('code', '=', 'IPHONE-6S')]) -iphone6s, = Product.get(products[0]['id']) - - -line1, = Line.create([{ - 'sale': sale['id'], - 'product': iphone6['id'], - 'description': iphone6['rec_name'], - 'unit': iphone6['default_uom'], - 'unit_price': iphone6['list_price'], - 'quantity': 3 -}]) - -line2, = Line.create([{ - 'sale': sale['id'], - 'product': iphone6s['id'], - 'description': iphone6s['rec_name'], - 'unit': iphone6s['default_uom'], - 'unit_price': iphone6s['list_price'], - 'quantity': 1 - -}]) - -# ============================ -# Creating Sale (Advanced) -# ============================ - -# Create sale with lines in single call! -sale, = Sale.create([{ - 'party': contact['id'], - 'shipment_address': contact['addresses'][0], - 'invoice_address': contact['addresses'][0], - 'lines': [('create', [{ - 'product': iphone6['id'], - 'description': iphone6['rec_name'], - 'unit': iphone6['default_uom'], - 'unit_price': iphone6['list_price'], - 'quantity': 3 - }, { - 'product': iphone6s['id'], - 'description': iphone6s['rec_name'], - 'unit': iphone6['default_uom'], - 'unit_price': iphone6s['list_price'], - 'quantity': 1 - }])] -}]) diff --git a/fulfil_client/__init__.py b/fulfil_client/__init__.py index ffe1346..32c54bb 100755 --- a/fulfil_client/__init__.py +++ b/fulfil_client/__init__.py @@ -1,13 +1,10 @@ # -*- coding: utf-8 -*- -__author__ = 'Fulfil.IO Inc.' -__email__ = 'hello@fulfil.io' -__version__ = '2.0.0' +__author__ = "Fulfil.IO Inc." +__email__ = "hello@fulfil.io" +__version__ = "2.0.0" # flake8: noqa -from .client import ( - Client, Model, SessionAuth, APIKeyAuth, BearerAuth, - verify_webhook -) +from .client import Client, Model, SessionAuth, APIKeyAuth, BearerAuth, verify_webhook from .exceptions import ClientError, UserError, ServerError diff --git a/fulfil_client/client.py b/fulfil_client/client.py index 35a90e6..d9b4052 100755 --- a/fulfil_client/client.py +++ b/fulfil_client/client.py @@ -13,13 +13,17 @@ from more_itertools import chunked from .serialization import dumps, loads from .exceptions import ( - UserError, ClientError, ServerError, AuthenticationError, RateLimitError + UserError, + ClientError, + ServerError, + AuthenticationError, + RateLimitError, ) from .signals import response_received from .exceptions import Error # noqa -request_logger = logging.getLogger('fulfil_client.request') +request_logger = logging.getLogger("fulfil_client.request") def json_response(function): @@ -30,13 +34,13 @@ def wrapper(*args, **kwargs): if rv.status_code == 400: # Usually an user error error = loads(rv.text) - if error.get('type') == 'UserError': + if error.get("type") == "UserError": # These are error messages meant to be displayed to the # user. raise UserError( - message=error.get('message'), - code=error.get('code'), - description=error.get('description'), + message=error.get("message"), + code=error.get("code"), + description=error.get("description"), ) else: # Some unknown error type. Raise a generic client error @@ -53,24 +57,23 @@ def wrapper(*args, **kwargs): # 4XX range errors always have a JSON response # with a code, message and description. error = rv.text - if rv.headers.get('Content-Type') == 'application/json': - error = loads(rv.text).get('message', error) - raise ClientError( - error, - rv.status_code - ) + if rv.headers.get("Content-Type") == "application/json": + error = loads(rv.text).get("message", error) + raise ClientError(error, rv.status_code) else: # 5XX Internal Server errors raise ServerError( - rv.text, rv.status_code, rv.headers.get('X-Sentry-ID') + rv.text, rv.status_code, rv.headers.get("X-Sentry-ID") ) return loads(rv.text) + return wrapper class SessionAuth(requests.auth.AuthBase): "Session Authentication" - type_ = 'Session' + + type_ = "Session" def __init__(self, login, user_id, session): self.login = login @@ -78,50 +81,57 @@ def __init__(self, login, user_id, session): self.session = session def __call__(self, r): - r.headers['Authorization'] = 'Session ' + base64.b64encode( - '%s:%s:%s' % (self.login, self.user_id, self.session) + r.headers["Authorization"] = "Session " + base64.b64encode( + "%s:%s:%s" % (self.login, self.user_id, self.session) ) return r class BearerAuth(requests.auth.AuthBase): "Bearer Authentication" - type_ = 'BearerAuth' + + type_ = "BearerAuth" def __init__(self, access_token): self.access_token = access_token def __call__(self, r): - r.headers['Authorization'] = 'Bearer ' + self.access_token + r.headers["Authorization"] = "Bearer " + self.access_token return r class APIKeyAuth(requests.auth.AuthBase): "API key based Authentication" - type_ = 'APIKey' + + type_ = "APIKey" def __init__(self, api_key): self.api_key = api_key def __call__(self, r): - r.headers['x-api-key'] = self.api_key + r.headers["x-api-key"] = self.api_key return r class Client(object): - - def __init__(self, subdomain, - api_key=None, context=None, auth=None, - user_agent="Python Client", base_url="fulfil.io", - retry_on_rate_limit=False): + def __init__( + self, + subdomain, + api_key=None, + context=None, + auth=None, + user_agent="Python Client", + base_url="fulfil.io", + retry_on_rate_limit=False, + ): self.subdomain = subdomain - if self.subdomain == 'localhost': - self.host = 'http://localhost:8000' + if self.subdomain == "localhost": + self.host = "http://localhost:8000" else: - self.host = 'https://{}.{}'.format(self.subdomain, base_url) + self.host = "https://{}.{}".format(self.subdomain, base_url) - self.base_url = '%s/api/v2' % self.host + self.base_url = "%s/api/v2" % self.host self.session = requests.Session() if api_key is not None: @@ -136,15 +146,17 @@ def __init__(self, subdomain, read=retries, connect=retries, backoff_factor=0.5, - status_forcelist=(429, ), + status_forcelist=(429,), ) adapter = HTTPAdapter(max_retries=retry) - self.session.mount('http://', adapter) - self.session.mount('https://', adapter) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) - self.session.headers.update({ - 'User-Agent': user_agent, - }) + self.session.headers.update( + { + "User-Agent": user_agent, + } + ) self.context = {} if context is not None: @@ -159,24 +171,22 @@ def set_auth(self, auth): if auth is None: return if isinstance(auth, BearerAuth): - self.base_url = '%s/api/v2' % self.host + self.base_url = "%s/api/v2" % self.host def set_user_agent(self, user_agent): - self.session.headers.update({ - 'User-Agent': user_agent - }) + self.session.headers.update({"User-Agent": user_agent}) def refresh_context(self): """ Get the default context of the user and save it """ - User = self.model('res.user') + User = self.model("res.user") self.context = User.get_preferences(True) return self.context def today(self): - Date = self.model('ir.date') + Date = self.model("ir.date") rv = Date.today() return rv @@ -208,25 +218,20 @@ def login(self, login, password, set_auth=False): """ rv = self.session.post( self.host, - dumps({ - "method": "common.db.login", - "params": [login, password] - }), + dumps({"method": "common.db.login", "params": [login, password]}), ) - rv = loads(rv.content)['result'] + rv = loads(rv.content)["result"] if set_auth: - self.set_auth( - SessionAuth(login, *rv) - ) + self.set_auth(SessionAuth(login, *rv)) return rv def is_auth_alive(self): "Return true if the auth is not expired, else false" - model = self.model('ir.model') + model = self.model("ir.model") try: model.search([], None, 1, None) except ClientError as err: - if err and err.message['code'] == 403: + if err and err.message["code"] == 403: return False raise except Exception: @@ -236,8 +241,8 @@ def is_auth_alive(self): class WizardSession(object): - """An object to represent a specific session - """ + """An object to represent a specific session""" + def __init__(self, wizard, context): self.wizard = wizard @@ -263,23 +268,16 @@ def execute(self, state, context=None): self.state = state while self.state != self.end_state: result = self.parse_result( - self.wizard.execute( - self.session_id, - self.data, - self.state, - ctx - ) + self.wizard.execute(self.session_id, self.data, self.state, ctx) ) - if 'view' in result: + if "view" in result: return result return result def parse_result(self, result): - if 'view' in result: - view = result['view'] - self.data[view['state']].update( - view['defaults'] - ) + if "view" in result: + view = result["view"] + self.data[view["state"]].update(view["defaults"]) else: self.state = self.end_state return result @@ -292,11 +290,10 @@ def delete(self): class Wizard(object): - def __init__(self, client, wizard_name, **kwargs): self.client = client self.wizard_name = wizard_name - self.context = kwargs.get('context', {}) + self.context = kwargs.get("context", {}) @contextmanager def session(self, **context): @@ -306,19 +303,17 @@ def session(self, **context): @property def path(self): - return '%s/wizard/%s' % (self.client.base_url, self.wizard_name) + return "%s/wizard/%s" % (self.client.base_url, self.wizard_name) @json_response def execute(self, session_id, data, state, context=None): ctx = self.client.context.copy() ctx.update(context or {}) - request_logger.debug( - "Wizard::%s.execute::%s" % (self.wizard_name, state) - ) + request_logger.debug("Wizard::%s.execute::%s" % (self.wizard_name, state)) rv = self.client.session.put( - self.path + '/execute', + self.path + "/execute", dumps([session_id, data, state]), - params={'context': dumps(ctx)} + params={"context": dumps(ctx)}, ) # Call response signal return rv @@ -329,9 +324,7 @@ def create(self, context=None): ctx.update(context or {}) request_logger.debug("Wizard::%s.create" % (self.wizard_name,)) rv = self.client.session.put( - self.path + '/create', - dumps([]), - params={'context': dumps(ctx)} + self.path + "/create", dumps([]), params={"context": dumps(ctx)} ) # Call response signal return rv @@ -339,10 +332,7 @@ def create(self, context=None): @json_response def delete(self, session_id): request_logger.debug("Wizard::%s.delete" % (self.wizard_name,)) - rv = self.client.session.put( - self.path + '/delete', - dumps([session_id]) - ) + rv = self.client.session.put(self.path + "/delete", dumps([session_id])) # Call response signal return rv @@ -369,7 +359,6 @@ def update(self, data=None, **kwargs): class Model(object): - def __init__(self, client, model_name): self.client = client self.model_name = model_name @@ -378,42 +367,42 @@ def __getattr__(self, name): @json_response def proxy_method(*args, **kwargs): context = self.client.context.copy() - context.update(kwargs.pop('context', {})) + context.update(kwargs.pop("context", {})) request_logger.debug( - "%s.%s::%s::%s" % ( - self.model_name, name, args, kwargs - ) + "%s.%s::%s::%s" % (self.model_name, name, args, kwargs) ) rv = self.client.session.put( - self.path + '/%s' % name, + self.path + "/%s" % name, dumps(args), params={ - 'context': dumps(context), - } + "context": dumps(context), + }, ) response_received.send(rv) return rv + return proxy_method @property def path(self): - return '%s/model/%s' % (self.client.base_url, self.model_name) + return "%s/model/%s" % (self.client.base_url, self.model_name) @json_response def get(self, id, context=None): ctx = self.client.context.copy() ctx.update(context or {}) rv = self.client.session.get( - self.path + '/%d' % id, + self.path + "/%d" % id, params={ - 'context': dumps(ctx), - } + "context": dumps(ctx), + }, ) response_received.send(rv) return rv - def search_read_all(self, domain, order, fields, batch_size=500, - context=None, offset=0, limit=None): + def search_read_all( + self, domain, order, fields, batch_size=500, context=None, offset=0, limit=None + ): """ An endless iterator that iterates over records. @@ -435,7 +424,12 @@ def search_read_all(self, domain, order, fields, batch_size=500, @json_response def find( - self, filter=None, page=1, per_page=10, fields=None, order=None, + self, + filter=None, + page=1, + per_page=10, + fields=None, + order=None, context=None, ): """ @@ -464,13 +458,13 @@ def find( rv = self.client.session.get( self.path, params={ - 'filter': dumps(filter or []), - 'page': page, - 'per_page': per_page, - 'field': fields, - 'order': dumps(order), - 'context': dumps(context or self.client.context), - } + "filter": dumps(filter or []), + "page": page, + "per_page": per_page, + "field": fields, + "order": dumps(order), + "context": dumps(context or self.client.context), + }, ) response_received.send(rv) return rv @@ -482,62 +476,58 @@ def attach(self, id, filename, url): :param filename: File name of attachment :param url: Public url to download file from. """ - Attachment = self.client.model('ir.attachment') + Attachment = self.client.model("ir.attachment") return Attachment.add_attachment_from_url( - filename, url, '%s,%s' % (self.model_name, id) + filename, url, "%s,%s" % (self.model_name, id) ) class Report(object): - def __init__(self, client, report_name): self.client = client self.report_name = report_name @property def path(self): - return '%s/report/%s' % (self.client.base_url, self.report_name) + return "%s/report/%s" % (self.client.base_url, self.report_name) @json_response def execute(self, records=None, data=None, **kwargs): context = self.client.context.copy() - context.update(kwargs.pop('context', {})) + context.update(kwargs.pop("context", {})) rv = self.client.session.put( self.path, json={ - 'objects': records or [], - 'data': data or {}, + "objects": records or [], + "data": data or {}, }, params={ - 'context': dumps(context), - } + "context": dumps(context), + }, ) response_received.send(rv) return rv class InteractiveReport(object): - def __init__(self, client, model_name): self.client = client self.model_name = model_name @property def path(self): - return '%s/model/%s/execute' % ( - self.client.base_url, self.model_name - ) + return "%s/model/%s/execute" % (self.client.base_url, self.model_name) @json_response def execute(self, **kwargs): context = self.client.context.copy() - context.update(kwargs.pop('context', {})) + context.update(kwargs.pop("context", {})) rv = self.client.session.put( self.path, dumps([kwargs]), params={ - 'context': dumps(context), - } + "context": dumps(context), + }, ) response_received.send(rv) return rv @@ -551,11 +541,11 @@ class AsyncResult(object): and result. """ - PENDING = 'PENDING' - STARTED = 'STARTED' - FAILURE = 'FAILURE' - SUCCESS = 'SUCCESS' - RETRY = 'RETRY' + PENDING = "PENDING" + STARTED = "STARTED" + FAILURE = "FAILURE" + SUCCESS = "SUCCESS" + RETRY = "RETRY" def __init__(self, task_id, token, client): self.task_id = task_id @@ -567,7 +557,7 @@ def __init__(self, task_id, token, client): @property def path(self): - return '%s/async-result' % (self.client.base_url) + return "%s/async-result" % (self.client.base_url) def bind(self, client): self.client = client @@ -582,11 +572,7 @@ def _fetch_result(self): ) rv = self.client.session.post( self.path, - json={ - 'tasks': [ - [self.task_id, self.token] - ] - }, + json={"tasks": [[self.task_id, self.token]]}, ) response_received.send(rv) return rv @@ -597,19 +583,17 @@ def refresh_if_needed(self): """ if self.state in (self.PENDING, self.STARTED): try: - response, = self._fetch_result()['tasks'] + (response,) = self._fetch_result()["tasks"] except (KeyError, ValueError): - raise Exception( - "Unable to find results for task." - ) + raise Exception("Unable to find results for task.") - if 'error' in response: + if "error" in response: self.state == self.FAILURE - raise ServerError(response['error']) + raise ServerError(response["error"]) - if 'state' in response: - self.state = response['state'] - self.result = response['result'] + if "state" in response: + self.state = response["state"] + self.result = response["result"] def failed(self): """ @@ -657,12 +641,7 @@ def verify_webhook(data, secret, hmac_header): :param hmac_header: Value of the header in the request """ digest = hmac.new( - base64.b64decode(secret), - data.encode('utf-8'), - hashlib.sha256 + base64.b64decode(secret), data.encode("utf-8"), hashlib.sha256 ).digest() computed_hmac = base64.b64encode(digest) - return hmac.compare_digest( - computed_hmac, - hmac_header.encode('utf-8') - ) + return hmac.compare_digest(computed_hmac, hmac_header.encode("utf-8")) diff --git a/fulfil_client/contrib/kombu.py b/fulfil_client/contrib/kombu.py index a483a5f..4b2b3b7 100644 --- a/fulfil_client/contrib/kombu.py +++ b/fulfil_client/contrib/kombu.py @@ -8,9 +8,8 @@ This is used in setuptools to register custom endpoint """ + from fulfil_client.serialization import dumps, loads, CONTENT_TYPE -register_args = ( - dumps, loads, CONTENT_TYPE, 'utf-8' -) +register_args = (dumps, loads, CONTENT_TYPE, "utf-8") diff --git a/fulfil_client/contrib/mail.py b/fulfil_client/contrib/mail.py index 8d2398a..19a2758 100644 --- a/fulfil_client/contrib/mail.py +++ b/fulfil_client/contrib/mail.py @@ -13,8 +13,14 @@ def render_email( - from_email, to, subject, text_template=None, html_template=None, - cc=None, attachments=None, **context + from_email, + to, + subject, + text_template=None, + html_template=None, + cc=None, + attachments=None, + **context, ): """ Read the templates for email messages, format them, construct @@ -39,18 +45,16 @@ def render_email( text_part = None if text_template: - text_part = MIMEText( - text_template.encode("utf-8"), 'plain', _charset="UTF-8") + text_part = MIMEText(text_template.encode("utf-8"), "plain", _charset="UTF-8") html_part = None if html_template: - html_part = MIMEText( - html_template.encode("utf-8"), 'html', _charset="UTF-8") + html_part = MIMEText(html_template.encode("utf-8"), "html", _charset="UTF-8") if text_part and html_part: # Construct an alternative part since both the HTML and Text Parts # exist. - message = MIMEMultipart('alternative') + message = MIMEMultipart("alternative") message.attach(text_part) message.attach(html_part) else: @@ -60,7 +64,7 @@ def render_email( if attachments: # If an attachment exists, the MimeType should be mixed and the # message body should just be another part of it. - message_with_attachments = MIMEMultipart('mixed') + message_with_attachments = MIMEMultipart("mixed") # Set the message body as the first part message_with_attachments.attach(message) @@ -69,32 +73,32 @@ def render_email( message = message_with_attachments for filename, content in attachments.items(): - part = MIMEBase('application', "octet-stream") + part = MIMEBase("application", "octet-stream") part.set_payload(content) Encoders.encode_base64(part) # XXX: Filename might have to be encoded with utf-8, # i.e., part's encoding or with email's encoding part.add_header( - 'Content-Disposition', 'attachment; filename="%s"' % filename + "Content-Disposition", 'attachment; filename="%s"' % filename ) message.attach(part) # If list of addresses are provided for to and cc, then convert it # into a string that is "," separated. if isinstance(to, (list, tuple)): - to = ', '.join(to) + to = ", ".join(to) if isinstance(cc, (list, tuple)): - cc = ', '.join(cc) + cc = ", ".join(cc) # We need to use Header objects here instead of just assigning the strings # in order to get our headers properly encoded (with QP). - message['Subject'] = Header(subject, 'ISO-8859-1') + message["Subject"] = Header(subject, "ISO-8859-1") # TODO handle case where domain contains non-ascii letters # https://docs.aws.amazon.com/ses/latest/APIReference/API_Destination.html - message['From'] = from_email - message['To'] = to + message["From"] = from_email + message["To"] = to if cc: - message['Cc'] = cc + message["Cc"] = cc return message diff --git a/fulfil_client/contrib/mocking.py b/fulfil_client/contrib/mocking.py index 07bea35..0b9bf59 100644 --- a/fulfil_client/contrib/mocking.py +++ b/fulfil_client/contrib/mocking.py @@ -10,10 +10,11 @@ class MockFulfil(object): A Mock object that helps mock away the Fulfil API for testing. """ + responses = [] models = {} context = {} - subdomain = 'mock-test' + subdomain = "mock-test" def __init__(self, target, responses=None): self.target = target @@ -31,9 +32,7 @@ def __exit__(self, type, value, traceback): return type is None def model(self, model_name): - return self.models.setdefault( - model_name, mock.MagicMock(name=model_name) - ) + return self.models.setdefault(model_name, mock.MagicMock(name=model_name)) def start(self): """ @@ -42,9 +41,7 @@ def start(self): self._patcher = mock.patch(target=self.target) MockClient = self._patcher.start() instance = MockClient.return_value - instance.model.side_effect = mock.Mock( - side_effect=self.model - ) + instance.model.side_effect = mock.Mock(side_effect=self.model) def stop(self): """ diff --git a/fulfil_client/exceptions.py b/fulfil_client/exceptions.py index b07b61f..3a82509 100644 --- a/fulfil_client/exceptions.py +++ b/fulfil_client/exceptions.py @@ -8,7 +8,10 @@ def __str__(self): return str(self.message) def __getnewargs__(self): - return (self.message, self.code,) + return ( + self.message, + self.code, + ) class ServerError(Error): @@ -37,6 +40,7 @@ class AuthenticationError(ClientError): This could be because a token expired or becuase the auth is just invalid. """ + pass @@ -48,6 +52,7 @@ class UserError(ClientError): to the user. User errors generally have a description too, so respect that too. """ + def __init__(self, message, code, description=None): self.description = description super(UserError, self).__init__(message, code) diff --git a/fulfil_client/model.py b/fulfil_client/model.py index 85b72c3..32b8703 100644 --- a/fulfil_client/model.py +++ b/fulfil_client/model.py @@ -5,6 +5,7 @@ A collection of model layer APIs to write lesser code and better """ + import six import logging import functools @@ -21,7 +22,7 @@ from future_builtins import zip -cache_logger = logging.getLogger('fulfil_client.cache') +cache_logger = logging.getLogger("fulfil_client.cache") class BaseType(object): @@ -61,60 +62,52 @@ def __delete__(self, instance): class IntType(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', int) + kwargs.setdefault("cast", int) super(IntType, self).__init__(*args, **kwargs) class BooleanType(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', bool) + kwargs.setdefault("cast", bool) super(BooleanType, self).__init__(*args, **kwargs) class StringType(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', six.text_type) + kwargs.setdefault("cast", six.text_type) super(StringType, self).__init__(*args, **kwargs) class DecimalType(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', Decimal) + kwargs.setdefault("cast", Decimal) super(DecimalType, self).__init__(*args, **kwargs) class FloatType(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', float) + kwargs.setdefault("cast", float) super(FloatType, self).__init__(*args, **kwargs) class DateTime(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', datetime) + kwargs.setdefault("cast", datetime) super(DateTime, self).__init__(*args, **kwargs) class Date(BaseType): - def __init__(self, *args, **kwargs): - kwargs.setdefault('cast', date) + kwargs.setdefault("cast", date) super(Date, self).__init__(*args, **kwargs) class One2ManyType(BaseType): - def __init__(self, model_name, cache=False, *args, **kwargs): self.model_name = model_name self.cache = cache - kwargs.setdefault('cast', list) + kwargs.setdefault("cast", list) super(One2ManyType, self).__init__(*args, **kwargs) def __get__(self, instance, owner): @@ -155,7 +148,7 @@ def __get__(self, instance, owner): return None return Money( instance._values.get(self.name, self.default), - getattr(instance, self.currency_field) + getattr(instance, self.currency_field), ) else: return self @@ -170,6 +163,7 @@ class ModelType(IntType): :param cache: If set, it looks up the record in the cache backend of the underlying model before querying the server to fetch records. """ + def __init__(self, model_name, cache=False, *args, **kwargs): self.model_name = model_name self.cache = cache @@ -192,27 +186,27 @@ class NamedDescriptorResolverMetaClass(type): """ def __new__(cls, classname, bases, class_dict): - abstract = class_dict.get('__abstract__', False) - model_name = class_dict.get('__model_name__') + abstract = class_dict.get("__abstract__", False) + model_name = class_dict.get("__model_name__") if not abstract and not model_name: for base in bases: - if hasattr(base, '__model_name__'): + if hasattr(base, "__model_name__"): model_name = base.__model_name__ break else: - raise Exception('__model_name__ not defined for model') + raise Exception("__model_name__ not defined for model") fields = set([]) eager_fields = set([]) for base in bases: - if hasattr(base, '_fields'): + if hasattr(base, "_fields"): fields |= set(base._fields) - if hasattr(base, '_eager_fields'): + if hasattr(base, "_eager_fields"): eager_fields |= set(base._eager_fields) - fields |= class_dict.get('_fields', set([])) - eager_fields |= class_dict.get('_eager_fields', set([])) + fields |= class_dict.get("_fields", set([])) + eager_fields |= class_dict.get("_eager_fields", set([])) # Iterate through the new class' __dict__ to: # @@ -226,8 +220,8 @@ def __new__(cls, classname, bases, class_dict): if attr.eager: eager_fields.add(name) - class_dict['_eager_fields'] = eager_fields - class_dict['_fields'] = fields | eager_fields + class_dict["_eager_fields"] = eager_fields + class_dict["_fields"] = fields | eager_fields # Call super and continue class creation rv = type.__new__(cls, classname, bases, class_dict) @@ -265,6 +259,7 @@ def wrapper(*args, **kwargs): map_fn = query.instance_class for record in function(*args, **kwargs): yield map_fn(record) if map_fn else record + return wrapper @@ -279,10 +274,11 @@ def wrapper(*args, **kwargs): return query.instance_class(**result) else: return result + return wrapper -class classproperty(object): # NOQA +class classproperty(object): # NOQA def __init__(self, f): self.f = f @@ -308,8 +304,7 @@ def __init__(self, model, instance_class=None): @property def fields(self): - return self.instance_class and tuple(self.instance_class._fields) or \ - None + return self.instance_class and tuple(self.instance_class._fields) or None def __copy__(self): """ @@ -335,7 +330,7 @@ def __copy__(self): def context(self): "Return the context to execute the query" return { - 'active_test': self.active_only, + "active_test": self.active_only, } def _copy(self): @@ -364,18 +359,14 @@ def all(self): def count(self): "Return a count of rows this Query would return." - return self.rpc_model.search_count( - self.domain, context=self.context - ) + return self.rpc_model.search_count(self.domain, context=self.context) def exists(self): """ A convenience method that returns True if a record satisfying the query exists """ - return self.rpc_model.search_count( - self.domain, context=self.context - ) > 0 + return self.rpc_model.search_count(self.domain, context=self.context) > 0 def show_active_only(self, state): """ @@ -392,9 +383,7 @@ def filter_by(self, **kwargs): """ query = self._copy() for field, value in kwargs.items(): - query.domain.append( - (field, '=', value) - ) + query.domain.append((field, "=", value)) return query def filter_by_domain(self, domain): @@ -412,8 +401,7 @@ def first(self): doesn't contain any row. """ results = self.rpc_model.search_read( - self.domain, None, 1, self._order_by, self.fields, - context=self.context + self.domain, None, 1, self._order_by, self.fields, context=self.context ) return results and results[0] or None @@ -426,11 +414,9 @@ def get(self, id): This returns a record whether active or not. """ ctx = self.context.copy() - ctx['active_test'] = False + ctx["active_test"] = False results = self.rpc_model.search_read( - [('id', '=', id)], - None, None, None, self.fields, - context=ctx + [("id", "=", id)], None, None, None, self.fields, context=ctx ) return results and results[0] or None @@ -460,8 +446,7 @@ def one(self): found. """ results = self.rpc_model.search_read( - self.domain, 2, None, self._order_by, self.fields, - context=self.context + self.domain, 2, None, self._order_by, self.fields, context=self.context ) if not results: raise fulfil_client.exc.NoResultFound @@ -509,7 +494,7 @@ def archive(self): """ ids = self.rpc_model.search(self.domain, context=self.context) if ids: - self.rpc_model.write(ids, {'active': False}) + self.rpc_model.write(ids, {"active": False}) @six.add_metaclass(NamedDescriptorResolverMetaClass) @@ -531,7 +516,7 @@ def __init__(self, values=None, id=None, **kwargs): values.update(kwargs) if id is not None: - values['id'] = id + values["id"] = id # Now create a modification tracking dictionary self._values = ModificationTrackingDict(values) @@ -539,11 +524,7 @@ def __init__(self, values=None, id=None, **kwargs): @classmethod def get_cache_key(cls, id): "Return a cache key for the given id" - return '%s:%s:%s' % ( - cls.fulfil_client.subdomain, - cls.__model_name__, - id - ) + return "%s:%s:%s" % (cls.fulfil_client.subdomain, cls.__model_name__, id) @property def cache_key(self): @@ -579,15 +560,13 @@ def from_cache_multi(cls, ids, ignore_misses=False): misses.append(id) if misses: - cache_logger.warn( - "MISS::MULTI::%s::%s" % (cls.__model_name__, misses) - ) + cache_logger.warn("MISS::MULTI::%s::%s" % (cls.__model_name__, misses)) if misses and not ignore_misses: # Get the records in bulk for misses rows = cls.rpc.read(misses, tuple(cls._fields)) for row in rows: - record = cls(id=row['id'], values=row) + record = cls(id=row["id"], values=row) record.store_in_cache() results.append(record) @@ -642,13 +621,15 @@ def changes(self): """ Return a set of changes """ - return dict([ - (field_name, self._values[field_name]) - for field_name in self._values.changes - ]) + return dict( + [ + (field_name, self._values[field_name]) + for field_name in self._values.changes + ] + ) @classproperty - def query(cls): # NOQA + def query(cls): # NOQA return Query(cls.get_rpc_model(), cls) @property @@ -657,7 +638,7 @@ def has_changed(self): return len(self._values) > 0 @classproperty - def rpc(cls): # NOQA + def rpc(cls): # NOQA "Returns an RPC client for the Fulfil.IO model with same name" return cls.get_rpc_model() @@ -726,21 +707,21 @@ def __eq__(self, other): @property def __url__(self): "Return the API URL for the record" - return '/'.join([ - self.rpc.client.base_url, - self.__model_name__, - six.text_type(self.id) - ]) + return "/".join( + [self.rpc.client.base_url, self.__model_name__, six.text_type(self.id)] + ) @property def __client_url__(self): "Return the Client URL for the record" - return '/'.join([ - self.rpc.client.host, - 'client/#/model', - self.__model_name__, - six.text_type(self.id) - ]) + return "/".join( + [ + self.rpc.client.host, + "client/#/model", + self.__model_name__, + six.text_type(self.id), + ] + ) def model_base(fulfil_client, cache_backend=None, cache_expire=10 * 60): @@ -751,13 +732,13 @@ def model_base(fulfil_client, cache_backend=None, cache_expire=10 * 60): This design is inspired by the declarative base pattern in SQL Alchemy. """ return type( - 'BaseModel', + "BaseModel", (Model,), { - 'fulfil_client': fulfil_client, - 'cache_backend': cache_backend, - 'cache_expire': cache_expire, - '__abstract__': True, - '__modelregistry__': {}, + "fulfil_client": fulfil_client, + "cache_backend": cache_backend, + "cache_expire": cache_expire, + "__abstract__": True, + "__modelregistry__": {}, }, ) diff --git a/fulfil_client/oauth.py b/fulfil_client/oauth.py index 4d1955d..e13a481 100644 --- a/fulfil_client/oauth.py +++ b/fulfil_client/oauth.py @@ -2,7 +2,6 @@ class Session(OAuth2Session): - client_id = None client_secret = None @@ -11,31 +10,27 @@ def __init__(self, subdomain, **kwargs): client_secret = self.client_secret self.fulfil_subdomain = subdomain if not (client_id and client_secret): - raise Exception('Missing client_id or client_secret.') + raise Exception("Missing client_id or client_secret.") super(Session, self).__init__(client_id=client_id, **kwargs) @classmethod def setup(cls, client_id, client_secret): - """Configure client in session - """ + """Configure client in session""" cls.client_id = client_id cls.client_secret = client_secret @property def base_url(self): - if self.fulfil_subdomain == 'localhost': - return 'http://localhost:8000/' + if self.fulfil_subdomain == "localhost": + return "http://localhost:8000/" else: - return 'https://%s.fulfil.io/' % self.fulfil_subdomain + return "https://%s.fulfil.io/" % self.fulfil_subdomain def create_authorization_url(self, redirect_uri, scope, **kwargs): self.redirect_uri = redirect_uri self.scope = scope - return self.authorization_url( - self.base_url + 'oauth/authorize', **kwargs) + return self.authorization_url(self.base_url + "oauth/authorize", **kwargs) def get_token(self, code): - token_url = self.base_url + 'oauth/token' - return self.fetch_token( - token_url, client_secret=self.client_secret, code=code - ) + token_url = self.base_url + "oauth/token" + return self.fetch_token(token_url, client_secret=self.client_secret, code=code) diff --git a/fulfil_client/serialization.py b/fulfil_client/serialization.py index 1e412c5..c2e2939 100644 --- a/fulfil_client/serialization.py +++ b/fulfil_client/serialization.py @@ -16,7 +16,6 @@ class JSONDecoder(object): - decoders = {} @classmethod diff --git a/fulfil_client/signals.py b/fulfil_client/signals.py index dc4eeae..7708ddd 100644 --- a/fulfil_client/signals.py +++ b/fulfil_client/signals.py @@ -1,23 +1,26 @@ # -*- coding: utf-8 -*- """ - flask.signals - ~~~~~~~~~~~~~ - Implements signals based on blinker if available, otherwise - falls silently back to a noop. +flask.signals +~~~~~~~~~~~~~ +Implements signals based on blinker if available, otherwise +falls silently back to a noop. - :copyright: (c) 2018 Fulfil.IO Inc. +:copyright: (c) 2018 Fulfil.IO Inc. - The blinker fallback code is inspired by Armin's implementation - on Flask. - :copyright: (c) 2015 by Armin Ronacher. +The blinker fallback code is inspired by Armin's implementation +on Flask. +:copyright: (c) 2015 by Armin Ronacher. - :license: BSD, see LICENSE for more details. +:license: BSD, see LICENSE for more details. """ + signals_available = False try: from blinker import Namespace + signals_available = True except ImportError: + class Namespace(object): def signal(self, name, doc=None): return _FakeSignal(name, doc) @@ -32,17 +35,22 @@ class _FakeSignal(object): def __init__(self, name, doc=None): self.name = name self.__doc__ = doc + def _fail(self, *args, **kwargs): - raise RuntimeError('signalling support is unavailable ' - 'because the blinker library is ' - 'not installed.') + raise RuntimeError( + "signalling support is unavailable " + "because the blinker library is " + "not installed." + ) + send = lambda *a, **kw: None - connect = disconnect = has_receivers_for = receivers_for = \ - temporarily_connected_to = connected_to = _fail + connect = disconnect = has_receivers_for = receivers_for = ( + temporarily_connected_to + ) = connected_to = _fail del _fail # Namespace for signals _signals = Namespace() -response_received = _signals.signal('response-received') +response_received = _signals.signal("response-received") diff --git a/setup.py b/setup.py index 38e2f23..f22ed57 100755 --- a/setup.py +++ b/setup.py @@ -8,58 +8,58 @@ from distutils.core import setup -with open('README.rst') as readme_file: +with open("README.rst") as readme_file: readme = readme_file.read() -with open('HISTORY.rst') as history_file: +with open("HISTORY.rst") as history_file: history = history_file.read() requirements = [ - 'pyjwt', - 'requests', - 'requests_oauthlib', - 'money', - 'babel', - 'six', - 'more-itertools', - 'isodate', + "pyjwt", + "requests", + "requests_oauthlib", + "money", + "babel", + "six", + "more-itertools", + "isodate", ] setup( - name='fulfil_client', - version='2.0.0', + name="fulfil_client", + version="2.0.0", description="Fulfil REST API Client in Python", - long_description=readme + '\n\n' + history, + long_description=readme + "\n\n" + history, author="Fulfil.IO Inc.", - author_email='hello@fulfil.io', - url='https://github.com/fulfilio/fulfil-python-api', + author_email="hello@fulfil.io", + url="https://github.com/fulfilio/fulfil-python-api", packages=[ - 'fulfil_client', - 'fulfil_client.contrib', + "fulfil_client", + "fulfil_client.contrib", ], package_dir={ - 'fulfil_client': 'fulfil_client', - 'fulfil_client.contrib': 'fulfil_client/contrib' + "fulfil_client": "fulfil_client", + "fulfil_client.contrib": "fulfil_client/contrib", }, entry_points={ - 'kombu.serializers': [ - 'fulfil = fulfil_client.contrib.kombu:register_args', + "kombu.serializers": [ + "fulfil = fulfil_client.contrib.kombu:register_args", ], }, include_package_data=True, install_requires=requirements, license="ISCL", zip_safe=False, - keywords='fulfil_client', + keywords="fulfil_client", classifiers=[ - 'Development Status :: 2 - Pre-Alpha', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: ISC License (ISCL)', - 'Natural Language :: English', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: ISC License (ISCL)", + "Natural Language :: English", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", ], - setup_requires=['pytest-runner'], - tests_require=['pytest', 'redis'], + setup_requires=["pytest-runner"], + tests_require=["pytest", "redis"], ) diff --git a/tests/conftest.py b/tests/conftest.py index 8c49e49..0232e3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Defines fixtures available to all tests.""" + import os import pytest @@ -11,14 +12,12 @@ @pytest.fixture def client(): - return Client('demo', os.environ['FULFIL_API_KEY']) + return Client("demo", os.environ["FULFIL_API_KEY"]) @pytest.fixture def oauth_client(): - return Client( - 'demo', auth=BearerAuth(os.environ['FULFIL_OAUTH_TOKEN']) - ) + return Client("demo", auth=BearerAuth(os.environ["FULFIL_OAUTH_TOKEN"])) @pytest.fixture @@ -29,6 +28,5 @@ def Model(client): @pytest.fixture def ModelWithCache(client): return model_base( - client, - cache_backend=redis.StrictRedis(host='localhost', port=6379, db=0) + client, cache_backend=redis.StrictRedis(host="localhost", port=6379, db=0) ) diff --git a/tests/test_data_structures.py b/tests/test_data_structures.py index b074f78..df4827f 100644 --- a/tests/test_data_structures.py +++ b/tests/test_data_structures.py @@ -5,6 +5,7 @@ pylint option block-disable """ + import pickle import pytest import random @@ -12,11 +13,12 @@ from babel.numbers import format_currency from money import Money -from fulfil_client.model import ( - ModificationTrackingDict, Query, StringType, MoneyType -) +from fulfil_client.model import ModificationTrackingDict, Query, StringType, MoneyType from fulfil_client.exceptions import ( - ServerError, ClientError, AuthenticationError, UserError + ServerError, + ClientError, + AuthenticationError, + UserError, ) @@ -25,51 +27,51 @@ def mtd(): """ Return a sample Modification Tracking Dictionary """ - return ModificationTrackingDict({ - 'a': 'apple', - 'b': 'box', - 'l': [1, 2, 3], - }) + return ModificationTrackingDict( + { + "a": "apple", + "b": "box", + "l": [1, 2, 3], + } + ) class TestModificationTrackingDict(object): - def test_no_changes_on_initial_dict(self, mtd): assert len(mtd.changes) == 0 def test_no_changes_on_same_value(self, mtd): - mtd['a'] = 'apple' # nothing changes + mtd["a"] = "apple" # nothing changes assert len(mtd.changes) == 0 def test_no_changes_on_same_value_on_update(self, mtd): - mtd.update({'a': 'apple'}) + mtd.update({"a": "apple"}) assert len(mtd.changes) == 0 def test_changes_on_setter(self, mtd): - mtd['b'] = 'ball' # big change + mtd["b"] = "ball" # big change assert len(mtd.changes) == 1 - assert 'b' in mtd.changes + assert "b" in mtd.changes def test_changes_on_update(self, mtd): - mtd.update({'b': 'ball'}) # big change + mtd.update({"b": "ball"}) # big change assert len(mtd.changes) == 1 - assert 'b' in mtd.changes + assert "b" in mtd.changes def test_changes_on_new_key(self, mtd): - mtd['c'] = 'cat' + mtd["c"] = "cat" assert len(mtd.changes) == 1 - assert 'c' in mtd.changes + assert "c" in mtd.changes @pytest.fixture def query(client): return Query( - client.model('res.user'), + client.model("res.user"), ) class TestQuery(object): - def test_copyability_of_query(self, query): query._copy() @@ -86,31 +88,33 @@ def test_query_all(self, query): @pytest.fixture def res_user_model(Model): class ResUserModel(Model): - __model_name__ = 'res.user' + __model_name__ = "res.user" name = StringType() + return ResUserModel @pytest.fixture def res_user_model_with_cache(ModelWithCache): class ResUserModel(ModelWithCache): - __model_name__ = 'res.user' + __model_name__ = "res.user" name = StringType() + return ResUserModel @pytest.fixture def sale_order_model(Model): class SaleOrderModel(Model): - __model_name__ = 'sale.sale' - _eager_fields = set(['currency.code']) + __model_name__ = "sale.sale" + _eager_fields = set(["currency.code"]) number = StringType() - total_amount = MoneyType('currency_code') + total_amount = MoneyType("currency_code") @property def currency_code(self): - return self._values['currency.code'] + return self._values["currency.code"] return SaleOrderModel @@ -118,13 +122,13 @@ def currency_code(self): @pytest.fixture def product_model(Model): class ProductModel(Model): - __model_name__ = 'product.product' + __model_name__ = "product.product" - list_price = MoneyType('currency_code') + list_price = MoneyType("currency_code") @property def currency_code(self): - return 'USD' + return "USD" return ProductModel @@ -132,34 +136,35 @@ def currency_code(self): @pytest.fixture def contact_model(Model): class ContactModel(Model): - __model_name__ = 'party.party' + __model_name__ = "party.party" name = StringType() - credit_limit_amount = MoneyType('currency_code') + credit_limit_amount = MoneyType("currency_code") @property def currency_code(self): - return 'USD' + return "USD" + return ContactModel @pytest.fixture def module_model(Model): class ModuleModel(Model): - __model_name__ = 'ir.module' + __model_name__ = "ir.module" name = StringType() + return ModuleModel class TestModel(object): - def test_model_change_tracking(self, res_user_model): user = res_user_model.query.first() user.name = user.name assert not bool(user.changes) user.name = "Not real name" - assert 'name' in user.changes + assert "name" in user.changes def test_equality_of_saved_records(self, res_user_model): user = res_user_model.query.first() @@ -196,20 +201,19 @@ def test_api_url(self, res_user_model): class TestMoneyType(object): - def test_display_format(self, sale_order_model): order = sale_order_model.query.first() assert isinstance(order.total_amount, Money) assert isinstance(order.total_amount.amount, Decimal) - assert order.total_amount.format('en_US') == format_currency( - order._values['total_amount'], - currency=order._values['currency.code'], - locale='en_US' + assert order.total_amount.format("en_US") == format_currency( + order._values["total_amount"], + currency=order._values["currency.code"], + locale="en_US", ) - assert order.total_amount.format('fr_FR') == format_currency( - order._values['total_amount'], - currency=order._values['currency.code'], - locale='fr_FR' + assert order.total_amount.format("fr_FR") == format_currency( + order._values["total_amount"], + currency=order._values["currency.code"], + locale="fr_FR", ) def test_setting_values(self, product_model): @@ -221,10 +225,9 @@ def test_setting_values(self, product_model): list_price = product_model.query.first().list_price assert list_price.amount == new_price - assert list_price.currency == 'USD' # hard coded in model property + assert list_price.currency == "USD" # hard coded in model property def test_none(self, contact_model): - contact = contact_model.query.first() contact.credit_limit_amount = None @@ -233,18 +236,17 @@ def test_none(self, contact_model): credit_limit = contact.query.first().credit_limit_amount assert credit_limit is None - contact.credit_limit_amount = Decimal('100000') + contact.credit_limit_amount = Decimal("100000") contact.save() credit_limit = contact.query.first().credit_limit_amount - assert credit_limit.amount == Decimal('100000') - assert credit_limit.currency == 'USD' # hard coded in model property + assert credit_limit.amount == Decimal("100000") + assert credit_limit.currency == "USD" # hard coded in model property -@pytest.mark.parametrize("error_class", [ - ServerError, ClientError, AuthenticationError, - UserError -]) +@pytest.mark.parametrize( + "error_class", [ServerError, ClientError, AuthenticationError, UserError] +) def test_exception_pickling(error_class): "Test that exceptions can be pickled" error = error_class("Shit Happens", "123") diff --git a/tests/test_fulfil_client.py b/tests/test_fulfil_client.py index aa90552..a18bf15 100755 --- a/tests/test_fulfil_client.py +++ b/tests/test_fulfil_client.py @@ -1,141 +1,118 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - """ test_fulfil_client ---------------------------------- Tests for `fulfil_client` module. """ + import pytest from fulfil_client import Client, ClientError, ServerError def test_find(client): - IRModel = client.model('ir.model') + IRModel = client.model("ir.model") ir_models = IRModel.find([]) assert len(ir_models) > 0 - assert ir_models[0]['id'] - assert ir_models[0]['rec_name'] + assert ir_models[0]["id"] + assert ir_models[0]["rec_name"] def test_search_read_all(client): - IRView = client.model('ir.ui.view') + IRView = client.model("ir.ui.view") total_records = IRView.search_count([]) - ir_models = list( - IRView.search_read_all([], None, ['rec_name'], batch_size=50) - ) + ir_models = list(IRView.search_read_all([], None, ["rec_name"], batch_size=50)) # the default batch size is 500 and the total records # being greater than that is an important part of the test. assert total_records > 500 - assert total_records == len(set([r['id'] for r in ir_models])) + assert total_records == len(set([r["id"] for r in ir_models])) assert len(ir_models) == total_records assert len(ir_models) > 10 - assert ir_models[0]['id'] - assert ir_models[0]['rec_name'] + assert ir_models[0]["id"] + assert ir_models[0]["rec_name"] first_record = ir_models[0] # Offset and then fetch - ir_models = list( - IRView.search_read_all( - [], None, ['rec_name'], offset=10 - ) - ) + ir_models = list(IRView.search_read_all([], None, ["rec_name"], offset=10)) assert len(ir_models) == total_records - 10 - assert ir_models[0]['id'] != first_record['id'] + assert ir_models[0]["id"] != first_record["id"] # Smaller batch size and offset ir_models = list( - IRView.search_read_all( - [], None, ['rec_name'], batch_size=5, offset=10 - ) + IRView.search_read_all([], None, ["rec_name"], batch_size=5, offset=10) ) assert len(ir_models) == total_records - 10 - assert ir_models[0]['id'] != first_record['id'] + assert ir_models[0]["id"] != first_record["id"] # Smaller batch size and limit ir_models = list( - IRView.search_read_all( - [], None, ['rec_name'], batch_size=5, limit=10 - ) + IRView.search_read_all([], None, ["rec_name"], batch_size=5, limit=10) ) assert len(ir_models) == 10 - assert ir_models[0]['id'] == first_record['id'] + assert ir_models[0]["id"] == first_record["id"] # default batch size and limit - ir_models = list( - IRView.search_read_all( - [], None, ['rec_name'], limit=10 - ) - ) + ir_models = list(IRView.search_read_all([], None, ["rec_name"], limit=10)) assert len(ir_models) == 10 - assert ir_models[0]['id'] == first_record['id'] + assert ir_models[0]["id"] == first_record["id"] # small batch size and limit and offset ir_models = list( - IRView.search_read_all( - [], None, ['rec_name'], - batch_size=5, limit=10, offset=5 - ) + IRView.search_read_all([], None, ["rec_name"], batch_size=5, limit=10, offset=5) ) assert len(ir_models) == 10 - assert ir_models[0]['id'] != first_record['id'] + assert ir_models[0]["id"] != first_record["id"] # default batch size and limit and offset - ir_models = list( - IRView.search_read_all( - [], None, ['rec_name'], - limit=10, offset=5 - ) - ) + ir_models = list(IRView.search_read_all([], None, ["rec_name"], limit=10, offset=5)) assert len(ir_models) == 10 - assert ir_models[0]['id'] != first_record['id'] + assert ir_models[0]["id"] != first_record["id"] def test_find_no_filter(client): - IRModel = client.model('ir.model') + IRModel = client.model("ir.model") ir_models = IRModel.find() assert len(ir_models) > 0 - assert ir_models[0]['id'] - assert ir_models[0]['rec_name'] + assert ir_models[0]["id"] + assert ir_models[0]["rec_name"] def test_raises_server_error(client): - Model = client.model('ir.model') + Model = client.model("ir.model") with pytest.raises(ServerError): Model.search(1) def test_raises_client_error(): with pytest.raises(ClientError): - Client('demo', 'wrong-api-key') + Client("demo", "wrong-api-key") def test_wizard_implementation(oauth_client): - DuplicateWizard = oauth_client.wizard('ir.model.duplicate') - Party = oauth_client.model('party.party') + DuplicateWizard = oauth_client.wizard("ir.model.duplicate") + Party = oauth_client.model("party.party") existing_parties = Party.search([], None, 1, None) if not existing_parties: pytest.fail("No existing parties to duplicate") - existing_party, = existing_parties + (existing_party,) = existing_parties with DuplicateWizard.session( - active_ids=[existing_party], active_id=existing_party, - active_model='party.party' + active_ids=[existing_party], + active_id=existing_party, + active_model="party.party", ) as wizard: - result = wizard.execute('duplicate_records') - assert 'actions' in result - action, data = result['actions'][0] - assert 'res_id' in data - assert len(data['res_id']) == 1 - Party.delete(data['res_id']) + result = wizard.execute("duplicate_records") + assert "actions" in result + action, data = result["actions"][0] + assert "res_id" in data + assert len(data["res_id"]) == 1 + Party.delete(data["res_id"]) def test_403(): "Connect with invalid creds and get ClientError" with pytest.raises(ClientError): - Client('demo', 'xxxx') + Client("demo", "xxxx") diff --git a/tests/test_mocking.py b/tests/test_mocking.py index 6997520..aa3b127 100644 --- a/tests/test_mocking.py +++ b/tests/test_mocking.py @@ -1,28 +1,23 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- import pytest import fulfil_client from fulfil_client.contrib.mocking import MockFulfil def api_calling_method(): - client = fulfil_client.Client('apple', 'apples-api-key') - Product = client.model('product.product') - products = Product.search_read_all([], None, ['id']) - Product.write( - [p['id'] for p in products], - {'active': False} - ) + client = fulfil_client.Client("apple", "apples-api-key") + Product = client.model("product.product") + products = Product.search_read_all([], None, ["id"]) + Product.write([p["id"] for p in products], {"active": False}) return client def test_mock_1(): - with MockFulfil('fulfil_client.Client') as mocked_fulfil: - Product = mocked_fulfil.model('product.product') + with MockFulfil("fulfil_client.Client") as mocked_fulfil: + Product = mocked_fulfil.model("product.product") Product.search_read_all.return_value = [ - {'id': 1}, - {'id': 2}, - {'id': 3}, + {"id": 1}, + {"id": 2}, + {"id": 3}, ] # Call the function @@ -30,30 +25,29 @@ def test_mock_1(): # Now assert Product.search_read_all.assert_called() - Product.search_read_all.assert_called_with([], None, ['id']) - Product.write.assert_called_with( - [1, 2, 3], {'active': False} - ) + Product.search_read_all.assert_called_with([], None, ["id"]) + Product.write.assert_called_with([1, 2, 3], {"active": False}) def test_mock_context(): "Ensure that old mocks die with the context" - with MockFulfil('fulfil_client.Client') as mocked_fulfil: - Product = mocked_fulfil.model('product.product') + with MockFulfil("fulfil_client.Client") as mocked_fulfil: + Product = mocked_fulfil.model("product.product") api_calling_method() Product.search_read_all.assert_called() # Start new context - with MockFulfil('fulfil_client.Client') as mocked_fulfil: - Product = mocked_fulfil.model('product.product') + with MockFulfil("fulfil_client.Client") as mocked_fulfil: + Product = mocked_fulfil.model("product.product") Product.search_read_all.assert_not_called() def test_mock_different_return_vals(): "Return different values based on mock side_effect" + def lookup_products(domain): - client = fulfil_client.Client('apple', 'apples-api-key') - Product = client.model('product.product') + client = fulfil_client.Client("apple", "apples-api-key") + Product = client.model("product.product") return Product.search(domain) def fake_search(domain): @@ -61,11 +55,11 @@ def fake_search(domain): # domain. if domain == []: return [1, 2, 3, 4, 5] - elif domain == [('salable', '=', True)]: + elif domain == [("salable", "=", True)]: return [1, 2, 3] - with MockFulfil('fulfil_client.Client') as mocked_fulfil: - Product = mocked_fulfil.model('product.product') + with MockFulfil("fulfil_client.Client") as mocked_fulfil: + Product = mocked_fulfil.model("product.product") Product.search.side_effect = fake_search assert lookup_products([]) == [1, 2, 3, 4, 5] - assert lookup_products([('salable', '=', True)]) == [1, 2, 3] + assert lookup_products([("salable", "=", True)]) == [1, 2, 3] diff --git a/travis_pypi_setup.py b/travis_pypi_setup.py deleted file mode 100755 index 5ba3a30..0000000 --- a/travis_pypi_setup.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -"""Update encrypted deploy password in Travis config file -""" - - -from __future__ import print_function -import base64 -import json -import os -from getpass import getpass -import yaml -from cryptography.hazmat.primitives.serialization import load_pem_public_key -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 - - -try: - from urllib import urlopen -except: - from urllib.request import urlopen - - -GITHUB_REPO = 'fulfilio/fulfil_client' -TRAVIS_CONFIG_FILE = os.path.join( - os.path.dirname(os.path.abspath(__file__)), '.travis.yml') - - -def load_key(pubkey): - """Load public RSA key, with work-around for keys using - incorrect header/footer format. - - Read more about RSA encryption with cryptography: - https://cryptography.io/latest/hazmat/primitives/asymmetric/rsa/ - """ - try: - return load_pem_public_key(pubkey.encode(), default_backend()) - except ValueError: - # workaround for https://github.com/travis-ci/travis-api/issues/196 - pubkey = pubkey.replace('BEGIN RSA', 'BEGIN').replace('END RSA', 'END') - return load_pem_public_key(pubkey.encode(), default_backend()) - - -def encrypt(pubkey, password): - """Encrypt password using given RSA public key and encode it with base64. - - The encrypted password can only be decrypted by someone with the - private key (in this case, only Travis). - """ - key = load_key(pubkey) - encrypted_password = key.encrypt(password, PKCS1v15()) - return base64.b64encode(encrypted_password) - - -def fetch_public_key(repo): - """Download RSA public key Travis will use for this repo. - - Travis API docs: http://docs.travis-ci.com/api/#repository-keys - """ - keyurl = 'https://api.travis-ci.org/repos/{0}/key'.format(repo) - data = json.loads(urlopen(keyurl).read().decode()) - if 'key' not in data: - errmsg = "Could not find public key for repo: {}.\n".format(repo) - errmsg += "Have you already added your GitHub repo to Travis?" - raise ValueError(errmsg) - return data['key'] - - -def prepend_line(filepath, line): - """Rewrite a file adding a line to its beginning. - """ - with open(filepath) as f: - lines = f.readlines() - - lines.insert(0, line) - - with open(filepath, 'w') as f: - f.writelines(lines) - - -def load_yaml_config(filepath): - with open(filepath) as f: - return yaml.load(f) - - -def save_yaml_config(filepath, config): - with open(filepath, 'w') as f: - yaml.dump(config, f, default_flow_style=False) - - -def update_travis_deploy_password(encrypted_password): - """Update the deploy section of the .travis.yml file - to use the given encrypted password. - """ - config = load_yaml_config(TRAVIS_CONFIG_FILE) - - config['deploy']['password'] = dict(secure=encrypted_password) - - save_yaml_config(TRAVIS_CONFIG_FILE, config) - - line = ('# This file was autogenerated and will overwrite' - ' each time you run travis_pypi_setup.py\n') - prepend_line(TRAVIS_CONFIG_FILE, line) - - -def main(args): - public_key = fetch_public_key(args.repo) - password = args.password or getpass('PyPI password: ') - update_travis_deploy_password(encrypt(public_key, password.encode())) - print("Wrote encrypted password to .travis.yml -- you're ready to deploy") - - -if '__main__' == __name__: - import argparse - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--repo', default=GITHUB_REPO, - help='GitHub repo (default: %s)' % GITHUB_REPO) - parser.add_argument('--password', - help='PyPI password (will prompt if not provided)') - - args = parser.parse_args() - main(args)