├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── docs ├── Makefile ├── conf.py └── index.rst ├── pycrest ├── __init__.py ├── cache.py ├── compat.py ├── errors.py └── eve.py ├── readme.md ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── test_errors.py └── test_pycrest.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pycrest 3 | branch = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bench.py 2 | distill.profile 3 | foo.mako 4 | run.py 5 | locustfile.py 6 | .coverage 7 | 8 | *.pyc 9 | 10 | *.egg-info/ 11 | *.egg/ 12 | .idea/ 13 | htmlcov/ 14 | build/ 15 | dist/ 16 | docs/_*/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "pypy" 8 | - "pypy3" 9 | install: 10 | - pip install . 11 | - pip install -r requirements.txt 12 | script: coverage run setup.py test 13 | after_success: coveralls 14 | notifications: 15 | email: 16 | - dreae@dreae.me 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - Dreae 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DistillFramework.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DistillFramework.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DistillFramework" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DistillFramework" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Distill Framework documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Sep 10 11:21:12 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | version = "0.0.1" 17 | 18 | # Add any paths that contain templates here, relative to this directory. 19 | templates_path = ['_templates'] 20 | 21 | # The suffix of source filenames. 22 | source_suffix = '.rst' 23 | 24 | # The encoding of source files. 25 | #source_encoding = 'utf-8-sig' 26 | 27 | # The master toctree document. 28 | master_doc = 'index' 29 | 30 | # General information about the project. 31 | project = 'PyCrest' 32 | copyright = '2015, Dreae' 33 | 34 | # The version info for the project you're documenting, acts as replacement for 35 | # |version| and |release|, also used in various other places throughout the 36 | # built documents. 37 | # 38 | # The full version, including alpha/beta/rc tags. 39 | release = version 40 | 41 | # The language for content autogenerated by Sphinx. Refer to documentation 42 | # for a list of supported languages. 43 | #language = None 44 | 45 | # There are two options for replacing |today|: either, you set today to some 46 | # non-false value, then it is used: 47 | #today = '' 48 | # Else, today_fmt is used as the format for a strftime call. 49 | #today_fmt = '%B %d, %Y' 50 | 51 | # List of patterns, relative to source directory, that match files and 52 | # directories to ignore when looking for source files. 53 | exclude_patterns = ['_build'] 54 | 55 | # The reST default role (used for this markup: `text`) to use for all 56 | # documents. 57 | #default_role = None 58 | 59 | # If true, '()' will be appended to :func: etc. cross-reference text. 60 | #add_function_parentheses = True 61 | 62 | # If true, the current module name will be prepended to all description 63 | # unit titles (such as .. function::). 64 | #add_module_names = True 65 | 66 | # If true, sectionauthor and moduleauthor directives will be shown in the 67 | # output. They are ignored by default. 68 | #show_authors = False 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = 'sphinx' 72 | 73 | # A list of ignored prefixes for module index sorting. 74 | #modindex_common_prefix = [] 75 | 76 | # If true, keep warnings as "system message" paragraphs in the built documents. 77 | #keep_warnings = False 78 | 79 | 80 | # -- Options for HTML output ---------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | #html_theme = 'sphinx_rtd_theme' 85 | 86 | # Theme options are theme-specific and customize the look and feel of a theme 87 | # further. For a list of options available for each theme, see the 88 | # documentation. 89 | #html_theme_options = {} 90 | 91 | # Add any paths that contain custom themes here, relative to this directory. 92 | #html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 93 | 94 | # The name for this set of Sphinx documents. If None, it defaults to 95 | # " v documentation". 96 | #html_title = None 97 | 98 | # A shorter title for the navigation bar. Default is the same as html_title. 99 | #html_short_title = None 100 | 101 | # The name of an image file (relative to this directory) to place at the top 102 | # of the sidebar. 103 | #html_logo = None 104 | 105 | # The name of an image file (within the static path) to use as favicon of the 106 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 107 | # pixels large. 108 | #html_favicon = None 109 | 110 | # Add any paths that contain custom static files (such as style sheets) here, 111 | # relative to this directory. They are copied after the builtin static files, 112 | # so a file named "default.css" will overwrite the builtin "default.css". 113 | html_static_path = ['_static'] 114 | 115 | # Add any extra paths that contain custom files (such as robots.txt or 116 | # .htaccess) here, relative to this directory. These files are copied 117 | # directly to the root of the documentation. 118 | #html_extra_path = [] 119 | 120 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 121 | # using the given strftime format. 122 | #html_last_updated_fmt = '%b %d, %Y' 123 | 124 | # If true, SmartyPants will be used to convert quotes and dashes to 125 | # typographically correct entities. 126 | #html_use_smartypants = True 127 | 128 | # Custom sidebar templates, maps document names to template names. 129 | #html_sidebars = {} 130 | 131 | # Additional templates that should be rendered to pages, maps page names to 132 | # template names. 133 | #html_additional_pages = {} 134 | 135 | # If false, no module index is generated. 136 | #html_domain_indices = True 137 | 138 | # If false, no index is generated. 139 | #html_use_index = True 140 | 141 | # If true, the index is split into individual pages for each letter. 142 | #html_split_index = False 143 | 144 | # If true, links to the reST sources are added to the pages. 145 | #html_show_sourcelink = True 146 | 147 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 148 | #html_show_sphinx = True 149 | 150 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 151 | #html_show_copyright = True 152 | 153 | # If true, an OpenSearch description file will be output, and all pages will 154 | # contain a tag referring to it. The value of this option must be the 155 | # base URL from which the finished HTML is served. 156 | #html_use_opensearch = '' 157 | 158 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 159 | #html_file_suffix = None 160 | 161 | # Output file base name for HTML help builder. 162 | htmlhelp_basename = 'PyCrest' 163 | 164 | 165 | # -- Options for LaTeX output --------------------------------------------- 166 | 167 | latex_elements = { 168 | # The paper size ('letterpaper' or 'a4paper'). 169 | #'papersize': 'letterpaper', 170 | 171 | # The font size ('10pt', '11pt' or '12pt'). 172 | #'pointsize': '10pt', 173 | 174 | # Additional stuff for the LaTeX preamble. 175 | #'preamble': '', 176 | } 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, 180 | # author, documentclass [howto, manual, or own class]). 181 | latex_documents = [ 182 | ('index', 'PyCrest.tex', 'PyCrest Documentation', 183 | 'Dreae', 'manual'), 184 | ] 185 | 186 | # The name of an image file (relative to this directory) to place at the top of 187 | # the title page. 188 | #latex_logo = None 189 | 190 | # For "manual" documents, if this is true, then toplevel headings are parts, 191 | # not chapters. 192 | #latex_use_parts = False 193 | 194 | # If true, show page references after internal links. 195 | #latex_show_pagerefs = False 196 | 197 | # If true, show URL addresses after external links. 198 | #latex_show_urls = False 199 | 200 | # Documents to append as an appendix to all manuals. 201 | #latex_appendices = [] 202 | 203 | # If false, no module index is generated. 204 | #latex_domain_indices = True 205 | 206 | 207 | # -- Options for manual page output --------------------------------------- 208 | 209 | # One entry per manual page. List of tuples 210 | # (source start file, name, description, authors, manual section). 211 | man_pages = [ 212 | ('index', 'pycrest', 'PyCrest Documentation', 213 | ['Dreae'], 1) 214 | ] 215 | 216 | # If true, show URL addresses after external links. 217 | #man_show_urls = False 218 | 219 | 220 | # -- Options for Texinfo output ------------------------------------------- 221 | 222 | # Grouping the document tree into Texinfo files. List of tuples 223 | # (source start file, target name, title, author, 224 | # dir menu entry, description, category) 225 | texinfo_documents = [ 226 | ('index', 'PyCrest', 'PyCrest Documentation', 227 | 'Dreae', 'PyCrest', 'One line description of project.', 228 | 'Miscellaneous'), 229 | ] 230 | 231 | # Documents to append as an appendix to all manuals. 232 | #texinfo_appendices = [] 233 | 234 | # If false, no module index is generated. 235 | #texinfo_domain_indices = True 236 | 237 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 238 | #texinfo_show_urls = 'footnote' 239 | 240 | # If true, do not generate a @detailmenu in the "Top" node's menu. 241 | #texinfo_no_detailmenu = False 242 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | PyCrest 3 | ======= 4 | 5 | PyCrest aims to provide a quick and easy way to interact with EVE Online's CREST API 6 | 7 | Installation 8 | ============ 9 | 10 | PyCrest can be installed from PyPi with pip:: 11 | 12 | $ pip install pycrest 13 | 14 | 15 | Getting Started 16 | =============== 17 | 18 | The entry point for the package should be the pycrest.EVE class 19 | 20 | .. highlight:: python 21 | 22 | >>> import pycrest 23 | >>> eve = pycrest.EVE() 24 | 25 | .. highlight:: none 26 | 27 | The above code will create an instance of the class that can be used for exploring the EVE public CREST data. The 28 | connection must be initialized before requests can be made to the CREST API. Loading is done by calling the class 29 | instance, and consecutive calls will not produce additional overhead: 30 | 31 | .. highlight:: python 32 | 33 | >>> eve() 34 | 35 | .. highlight:: none 36 | 37 | Attempting to access CREST resources before the connection is loaded will produce an exception. 38 | 39 | Resources for the CREST data are mapped as attributes on the EVE class, allowing you to easily traverse them: 40 | 41 | .. highlight:: python 42 | 43 | >>> eve.incursions 44 | {u'href': u'https://crest-tq.eveonline.com/incursions/'} 45 | 46 | .. highlight:: none 47 | 48 | In order to access resources that must be fetched from the API first, you must call the 49 | desired resource: 50 | 51 | .. highlight:: python 52 | 53 | >>> eve.incursions 54 | {u'href': u'https://crest-tq.eveonline.com/incursions/'} 55 | >>> eve.incursions() 56 | {u'items': [{...}], u'totalCount_str': u'5', u'totalCount': 5, u'pageCount': 1, u'pageCount_str': u'1'} 57 | >>> eve.incursions().totalCount 58 | 5 59 | 60 | .. highlight:: none 61 | 62 | Some useful helper methods to make your life easier / improve readability of next example: 63 | 64 | .. highlight:: python 65 | 66 | >>> def getByAttrVal(objlist, attr, val): 67 | ... ''' Searches list of dicts for a dict with dict[attr] == val ''' 68 | ... matches = [getattr(obj, attr) == val for obj in objlist] 69 | ... index = matches.index(True) # find first match, raise ValueError if not found 70 | ... return objlist[index] 71 | ... 72 | >>> def getAllItems(page): 73 | ... ''' Fetch data from all pages ''' 74 | ... ret = page().items 75 | ... while hasattr(page(), 'next'): 76 | ... page = page().next() 77 | ... ret.extend(page().items) 78 | ... return ret 79 | ... 80 | 81 | .. highlight:: none 82 | 83 | You can also pass parameters to resources supporting/requiring them, eg. `type` parameter for the regional 84 | market data endpoint: 85 | 86 | .. highlight:: python 87 | 88 | >>> region = getByAttrVal(eve.regions().items, 'name', 'Catch') 89 | >>> item = getByAttrVal(getAllItems(eve.itemTypes), 'name', 'Tritanium').href 90 | >>> getAllItems(region().marketSellOrders(type=item)) 91 | [{u'price': 9.29, u'volume': 1766874, u'location': {'name': u'V-3YG7 VI - EMMA STONE NUMBER ONE', ...}, ...}, ... ] 92 | 93 | .. highlight:: none 94 | 95 | By default resources are cached in-memory, you can change this behaviour by passing the `cache` keyword 96 | argument to the EVE class, with an instance of an object that implements APICache. There are several caches 97 | available in the pycrest.cache module. 98 | 99 | pycrest.cache.DictCache is the default cache and caches requests in memory. Any cached objects will be lost when the process terminates. 100 | pycrest.cache.FileCache caches requests on disc. It has one keyword argument named 'path' which configures a directory where file objects will be stored. 101 | pycrest.cache.MemcachedCache connects to a memcached server and stores objects in memory. This is very fast and has the advantage over DictCache that the cached objects can be shared across multiple processes. It has one keyword argument named 'server_list' which is a list of memached servers to use (for example ['127.0.0.1:11211']). This cache requires python-memcached to be installed. 102 | pycrest.cache.DummyCache doesn't cache anything. 103 | 104 | .. highlight:: python 105 | 106 | >>> import pycrest 107 | >>> from pycrest.cache import FileCache 108 | >>> file_cache = FileCache(path='/tmp/pycrest_cache') 109 | >>> eve = pycrest.EVE(cache=file_cache) 110 | 111 | .. highlight:: none 112 | 113 | Authorized Connections 114 | ====================== 115 | 116 | PyCrest can also be used for accessing CREST resources that require an authorized connection. To do so you must 117 | provide the EVE class with a `client_id`, `api_key`, and `redirect_uri` for the OAuth flows for authorizing a client. 118 | Once done, PyCrest can be used for obtaining an authorization token (short-lived) and a refresh token (long-lived): 119 | 120 | .. highlight:: python 121 | 122 | >>> eve = pycrest.EVE(client_id="your_client_id", api_key="your_api_key", redirect_uri="https://your.site/crest") 123 | >>> eve.auth_uri(scopes=['publicData'], state="foobar") 124 | 'https://login.eveonline.com/oauth/authorize?response_type=code&redirect_uri=...' 125 | 126 | .. highlight:: none 127 | 128 | Once you have redirected the client to acquire authorization, you may pass the returned code to `EVE.authorize()` to 129 | create an authorized connection. The code is returned as a parameter of the callback URI that you specified when you registered your application with CCP. For example, if your callback URI is 'https://callback.example.com/callback/' then the code would be returned by redirecting to something like 'https://callback.example.com/callback/?code=a1b2c3djsdfklsdfjklfsdjflk'. 130 | 131 | .. highlight:: python 132 | 133 | >>> eve.authorize(code) 134 | 135 | 136 | .. highlight:: none 137 | 138 | The authorized API connection functions identically to the public connection, except that requests will be directed 139 | to the authorized CREST endpoint. You can retrieve information about the authorized character by calling `whoami()` 140 | on an authorized connection: 141 | 142 | .. highlight:: python 143 | 144 | >>> con = eve.authorize(code) 145 | >>> con.whoami() 146 | {u'Scopes': u'publicData', u'CharacterName': u'Dreae', ...} 147 | 148 | .. highlight:: none 149 | 150 | Note that currently CREST authorization tokens expire after 1200 seconds and are automatically refreshed upon expiry. 151 | You can also refresh tokens manually by calling `refresh()` on the authorized connection. This refreshes the connection 152 | in-place and also returns `self` for backward compatibility. 153 | 154 | .. highlight:: python 155 | 156 | >>> con.refresh() 157 | 158 | 159 | .. highlight:: none 160 | 161 | Refresh Tokens 162 | -------------- 163 | 164 | Once the authorization token has expired (perhaps after you restart the application), you can obtain a new authorization token either by having the user go through the log-in process again or by using the long-lived refresh token to get a new authorization token without involving the user. The OAuth2 refresh token is stored inside the AuthedConnection object you obtained earlier, so you should persist this token somewhere safe for later use. Note that the refresh_token should be stored securely, as it allows anyone who possesses it access to whichever scopes you authorized. To get a new authorization token from the refresh token, pass the refresh token to refr_authorize(), and you have an AuthedConnection object ready to access CREST. 165 | 166 | .. highlight:: python 167 | 168 | >>> con = eve.authorize(returnedCode) 169 | >>> con 170 | 171 | >>> con.refresh_token 172 | u'djsdfjklsdf9sd8f908sdf9sd9f8sd9f8sdf8sp9fd89psdf89spdf89spdf89spdf89p' 173 | 174 | 175 | >>> eve.refr_authorize(refresh_token) 176 | 177 | 178 | .. highlight:: none 179 | 180 | Prevent PyCrest from using cache 181 | -------------------------------- 182 | 183 | **No cache for everything in PyCrest** 184 | This will disable the cache for everything you will do using PyCrest. No call or response will be stored. 185 | 186 | .. highlight:: python 187 | 188 | >>> pycrest_no_cache = EVE(cache=None) 189 | 190 | .. highlight:: none 191 | 192 | **Disable caching on demand** 193 | You can disable the caching for a specific ``get()`` call you don't want to cache, by simply adding ``caching=False|None`` to the call parameters. 194 | For example: 195 | 196 | .. highlight:: python 197 | 198 | >>> crest_with_caching = EVE() 199 | >>> crest_root_not_cached = crest_with_caching(caching=False) 200 | >>> regions = crest_root.regions(caching=False) 201 | 202 | .. highlight:: none 203 | -------------------------------------------------------------------------------- /pycrest/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class NullHandler(logging.Handler): 5 | 6 | def emit(self, record): 7 | pass 8 | 9 | logger = logging.getLogger('pycrest') 10 | logger.addHandler(NullHandler()) 11 | 12 | version = "0.0.6" 13 | 14 | from .eve import EVE 15 | -------------------------------------------------------------------------------- /pycrest/cache.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import zlib 3 | import os 4 | 5 | try: 6 | import pickle 7 | except ImportError: # pragma: no cover 8 | import cPickle as pickle 9 | 10 | import logging 11 | logger = logging.getLogger("pycrest.cache") 12 | 13 | 14 | class APICache(object): 15 | 16 | def put(self, key, value): 17 | raise NotImplementedError 18 | 19 | def get(self, key): 20 | raise NotImplementedError 21 | 22 | def invalidate(self, key): 23 | raise NotImplementedError 24 | 25 | def _hash(self, data): 26 | h = hashlib.new('md5') 27 | h.update(pickle.dumps(data)) 28 | # prefix allows possibility of multiple applications 29 | # sharing same keyspace 30 | return 'pyc_' + h.hexdigest() 31 | 32 | 33 | class FileCache(APICache): 34 | 35 | def __init__(self, path): 36 | self._cache = {} 37 | self.path = path 38 | if not os.path.isdir(self.path): 39 | os.mkdir(self.path, 0o700) 40 | 41 | def _getpath(self, key): 42 | return os.path.join(self.path, self._hash(key) + '.cache') 43 | 44 | def put(self, key, value): 45 | with open(self._getpath(key), 'wb') as f: 46 | f.write( 47 | zlib.compress( 48 | pickle.dumps(value, 49 | pickle.HIGHEST_PROTOCOL))) 50 | self._cache[key] = value 51 | 52 | def get(self, key): 53 | if key in self._cache: 54 | return self._cache[key] 55 | 56 | try: 57 | with open(self._getpath(key), 'rb') as f: 58 | return pickle.loads(zlib.decompress(f.read())) 59 | except IOError as ex: 60 | logger.debug('IOError: {0}'.format(ex)) 61 | if ex.errno == 2: # file does not exist (yet) 62 | return None 63 | else: # pragma: no cover 64 | raise 65 | 66 | def invalidate(self, key): 67 | self._cache.pop(key, None) 68 | 69 | try: 70 | os.unlink(self._getpath(key)) 71 | except OSError as ex: 72 | if ex.errno == 2: # does not exist 73 | pass 74 | else: # pragma: no cover 75 | raise 76 | 77 | 78 | class DictCache(APICache): 79 | 80 | def __init__(self): 81 | self._dict = {} 82 | 83 | def get(self, key): 84 | return self._dict.get(key, None) 85 | 86 | def put(self, key, value): 87 | self._dict[key] = value 88 | 89 | def invalidate(self, key): 90 | self._dict.pop(key, None) 91 | 92 | 93 | class DummyCache(APICache): 94 | """ Provide a fake cache class to allow a "no cache" 95 | use without breaking everything within APIConnection """ 96 | def __init__(self): 97 | self._dict = {} 98 | 99 | def get(self, key): 100 | return None 101 | 102 | def put(self, key, value): 103 | pass 104 | 105 | def invalidate(self, key): 106 | pass 107 | 108 | 109 | class MemcachedCache(APICache): 110 | 111 | def __init__(self, server_list): 112 | """ server_list is a list of memcached servers to connect to, 113 | for example ['127.0.0.1:11211']. 114 | """ 115 | # import memcache here so that the dependency on the python-memcached 116 | # only occurs if you use it 117 | import memcache 118 | self._mc = memcache.Client(server_list, debug=0) 119 | 120 | def get(self, key): 121 | return self._mc.get(self._hash(key)) 122 | 123 | def put(self, key, value): 124 | return self._mc.set(self._hash(key), value) 125 | 126 | def invalidate(self, key): 127 | return self._mc.delete(self._hash(key)) 128 | -------------------------------------------------------------------------------- /pycrest/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | PY3 = sys.version_info[0] == 3 4 | 5 | if PY3: # pragma: no cover 6 | string_types = str, 7 | text_type = str 8 | binary_type = bytes 9 | else: # pragma: no cover 10 | string_types = basestring, 11 | text_type = unicode 12 | binary_type = str 13 | 14 | 15 | def text_(s, encoding='latin-1', errors='strict'): # pragma: no cover 16 | if isinstance(s, binary_type): 17 | return s.decode(encoding, errors) 18 | return s 19 | 20 | 21 | def bytes_(s, encoding='latin-1', errors='strict'): # pragma: no cover 22 | if isinstance(s, text_type): 23 | return s.encode(encoding, errors) 24 | return s 25 | -------------------------------------------------------------------------------- /pycrest/errors.py: -------------------------------------------------------------------------------- 1 | class APIException(Exception): 2 | def __init__(self, url, code, json_response): 3 | 4 | self.url = url 5 | self.status_code = code 6 | self.response = json_response 7 | 8 | def __str__(self): 9 | if 'error' in self.response: 10 | return 'HTTP Error %s: %s' % (self.status_code, 11 | self.response['error']) 12 | elif 'message' in self.response: 13 | return 'HTTP Error %s: %s' % (self.status_code, 14 | self.response['message']) 15 | else: 16 | return 'HTTP Error %s' % (self.status_code) 17 | 18 | 19 | class UnsupportedHTTPMethodException(Exception): 20 | def __init__(self, method): 21 | self.method = method 22 | 23 | def __str__(self): 24 | return 'Unsupported HTTP Method: %s' % (self.method) 25 | -------------------------------------------------------------------------------- /pycrest/eve.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import requests 3 | import time 4 | from pycrest import version 5 | from pycrest.compat import bytes_, text_ 6 | from pycrest.errors import APIException, UnsupportedHTTPMethodException 7 | from requests.adapters import HTTPAdapter 8 | try: 9 | from urllib.parse import urlparse, urlunparse, parse_qsl 10 | except ImportError: # pragma: no cover 11 | from urlparse import urlparse, urlunparse, parse_qsl 12 | 13 | try: 14 | from urllib.parse import quote 15 | except ImportError: # pragma: no cover 16 | from urllib import quote 17 | import logging 18 | import re 19 | from pycrest.cache import DictCache, APICache, DummyCache 20 | 21 | logger = logging.getLogger("pycrest.eve") 22 | cache_re = re.compile(r'max-age=([0-9]+)') 23 | 24 | 25 | class APIConnection(object): 26 | 27 | def __init__( 28 | self, 29 | additional_headers=None, 30 | user_agent=None, 31 | transport_adapter=None, 32 | **kwargs): 33 | '''Initialises a PyCrest object 34 | 35 | Keyword arguments: 36 | additional_headers - a list of http headers that will be sent to the server 37 | user_agent - a custom user agent 38 | cache - an instance of an APICache object that will cache HTTP Requests. 39 | Default is DictCache, pass cache=None to disable caching. 40 | ''' 41 | # Set up a Requests Session 42 | session = requests.Session() 43 | if additional_headers is None: 44 | additional_headers = {} 45 | if user_agent is None: 46 | user_agent = "PyCrest/{0} +https://github.com/pycrest/PyCrest"\ 47 | .format(version) 48 | if isinstance(transport_adapter, HTTPAdapter): 49 | session.mount('http://', transport_adapter) 50 | session.mount('https://', transport_adapter) 51 | 52 | session.headers.update({ 53 | "User-Agent": user_agent, 54 | "Accept": "application/json", 55 | }) 56 | session.headers.update(additional_headers) 57 | self._session = session 58 | if 'cache' not in kwargs: 59 | self.cache = DictCache() 60 | else: 61 | cache = kwargs.pop('cache') 62 | if isinstance(cache, APICache): 63 | self.cache = cache 64 | elif cache is None: 65 | self.cache = DummyCache() 66 | else: 67 | raise ValueError('Provided cache must implement APICache') 68 | 69 | def _parse_parameters(self, resource, params): 70 | '''Creates a dictionary from query_string and `params` 71 | 72 | Transforms the `?key=value&...` to a {'key': 'value'} and adds 73 | (or overwrites if already present) the value with the dictionary in 74 | `params`. 75 | ''' 76 | # remove params from resource URI (needed for paginated stuff) 77 | parsed_uri = urlparse(resource) 78 | qs = parsed_uri.query 79 | resource = urlunparse(parsed_uri._replace(query='')) 80 | prms = {} 81 | for tup in parse_qsl(qs): 82 | prms[tup[0]] = tup[1] 83 | 84 | # params supplied to self.get() override parsed params 85 | for key in params: 86 | prms[key] = params[key] 87 | return resource, prms 88 | 89 | def get(self, resource, params={}, caching=True): 90 | logger.debug('Getting resource %s', resource) 91 | resource, prms = self._parse_parameters(resource, params) 92 | 93 | # check cache 94 | key = ( 95 | resource, frozenset( 96 | self._session.headers.items()), frozenset( 97 | prms.items())) 98 | cached = self.cache.get(key) 99 | if cached and cached['expires'] > time.time(): 100 | logger.debug( 101 | 'Cache hit for resource %s (params=%s)', 102 | resource, 103 | prms) 104 | return cached['payload'] 105 | elif cached: 106 | logger.debug( 107 | 'Cache stale for resource %s (params=%s)', 108 | resource, 109 | prms) 110 | self.cache.invalidate(key) 111 | else: 112 | logger.debug( 113 | 'Cache miss for resource %s (params=%s', resource, prms) 114 | 115 | logger.debug('Getting resource %s (params=%s)', resource, prms) 116 | res = self._session.get(resource, params=prms) 117 | 118 | if res.status_code != 200: 119 | raise APIException( 120 | resource, 121 | res.status_code, 122 | res.json() 123 | ) 124 | 125 | ret = res.json() 126 | 127 | # cache result only if caching = True (default) 128 | key = ( 129 | resource, frozenset( 130 | self._session.headers.items()), frozenset( 131 | prms.items())) 132 | 133 | expires = self._get_expires(res) 134 | if expires > 0 and caching: 135 | self.cache.put( 136 | key, { 137 | 'expires': time.time() + expires, 'payload': ret}) 138 | 139 | return ret 140 | 141 | # post is not idempotent so there should be no caching 142 | def post(self, resource, data={}): 143 | logger.debug('Posting resource %s (data=%s)', resource, data) 144 | res = self._session.post(resource, data=data) 145 | if res.status_code not in [200, 201]: 146 | raise APIException( 147 | resource, 148 | res.status_code, 149 | res.json() 150 | ) 151 | 152 | return {} 153 | 154 | # put is not idempotent so there should be no caching 155 | def put(self, resource, data={}): 156 | logger.debug('Putting resource %s (data=%s)', resource, data) 157 | res = self._session.put(resource, data=data) 158 | if res.status_code != 200: 159 | raise APIException( 160 | resource, 161 | res.status_code, 162 | res.json() 163 | ) 164 | 165 | return {} 166 | 167 | # delete is not idempotent so there should be no caching 168 | def delete(self, resource): 169 | logger.debug('Deleting resource %s', resource) 170 | res = self._session.delete(resource) 171 | if res.status_code != 200: 172 | raise APIException( 173 | resource, 174 | res.status_code, 175 | res.json() 176 | ) 177 | 178 | return {} 179 | 180 | def _get_expires(self, response): 181 | if 'Cache-Control' not in response.headers: 182 | return 0 183 | if any([s in response.headers['Cache-Control'] 184 | for s in ['no-cache', 'no-store']]): 185 | return 0 186 | match = cache_re.search(response.headers['Cache-Control']) 187 | if match: 188 | return int(match.group(1)) 189 | return 0 190 | 191 | 192 | class EVE(APIConnection): 193 | 194 | def __init__(self, **kwargs): 195 | self.api_key = kwargs.pop('api_key', None) 196 | self.client_id = kwargs.pop('client_id', None) 197 | self.redirect_uri = kwargs.pop('redirect_uri', None) 198 | if kwargs.pop('testing', False): 199 | self._endpoint = "https://api-sisi.testeveonline.com/" 200 | self._image_server = "https://image.testeveonline.com/" 201 | self._oauth_endpoint = "https://sisilogin.testeveonline.com/oauth" 202 | else: 203 | self._endpoint = "https://crest-tq.eveonline.com/" 204 | self._image_server = "https://imageserver.eveonline.com/" 205 | self._oauth_endpoint = "https://login.eveonline.com/oauth" 206 | self._cache = {} 207 | self._data = None 208 | APIConnection.__init__(self, **kwargs) 209 | 210 | def __call__(self, caching=True): 211 | if not self._data: 212 | self._data = APIObject(self.get(self._endpoint, 213 | caching=caching), 214 | self) 215 | return self._data 216 | 217 | def __getattr__(self, item): 218 | return self._data.__getattr__(item) 219 | 220 | def auth_uri(self, scopes=None, state=None): 221 | s = [] if not scopes else scopes 222 | return "%s/authorize?response_type=code&redirect_uri=%s&client_id=%s%s%s" % ( 223 | self._oauth_endpoint, 224 | quote(self.redirect_uri, safe=''), 225 | self.client_id, 226 | "&scope=%s" % '+'.join(s) if scopes else '', 227 | "&state=%s" % state if state else '' 228 | ) 229 | 230 | def _authorize(self, params): 231 | auth = text_( 232 | base64.b64encode( 233 | bytes_( 234 | "%s:%s" % 235 | (self.client_id, self.api_key)))) 236 | headers = {"Authorization": "Basic %s" % auth} 237 | resource = "%s/token" % self._oauth_endpoint 238 | res = self._session.post( 239 | resource, 240 | params=params, 241 | headers=headers) 242 | if res.status_code != 200: 243 | raise APIException( 244 | resource, 245 | res.status_code, 246 | res.json() 247 | ) 248 | 249 | return res.json() 250 | 251 | def authorize(self, code): 252 | res = self._authorize( 253 | params={ 254 | "grant_type": "authorization_code", 255 | "code": code}) 256 | return AuthedConnection(res, 257 | self._endpoint, 258 | self._oauth_endpoint, 259 | self.client_id, 260 | self.api_key, 261 | cache=self.cache) 262 | 263 | def refr_authorize(self, refresh_token): 264 | res = self._authorize( 265 | params={ 266 | "grant_type": "refresh_token", 267 | "refresh_token": refresh_token}) 268 | return AuthedConnection({'access_token': res['access_token'], 269 | 'refresh_token': refresh_token, 270 | 'expires_in': res['expires_in']}, 271 | self._endpoint, 272 | self._oauth_endpoint, 273 | self.client_id, 274 | self.api_key, 275 | cache=self.cache) 276 | 277 | def temptoken_authorize(self, access_token, expires_in, refresh_token): 278 | return AuthedConnection({'access_token': access_token, 279 | 'refresh_token': refresh_token, 280 | 'expires_in': expires_in}, 281 | self._endpoint, 282 | self._oauth_endpoint, 283 | self.client_id, 284 | self.api_key, 285 | cache=self.cache) 286 | 287 | 288 | class AuthedConnection(EVE): 289 | 290 | def __init__( 291 | self, 292 | res, 293 | endpoint, 294 | oauth_endpoint, 295 | client_id=None, 296 | api_key=None, 297 | **kwargs): 298 | EVE.__init__(self, **kwargs) 299 | self.client_id = client_id 300 | self.api_key = api_key 301 | self.token = res['access_token'] 302 | self.refresh_token = res['refresh_token'] 303 | self.expires = int(time.time()) + res['expires_in'] 304 | self._oauth_endpoint = oauth_endpoint 305 | self._endpoint = endpoint 306 | self._session.headers.update( 307 | {"Authorization": "Bearer %s" % self.token}) 308 | 309 | def __call__(self, caching=True): 310 | if not self._data: 311 | self._data = APIObject(self.get(self._endpoint, caching=caching), self) 312 | return self._data 313 | 314 | def whoami(self): 315 | if 'whoami' not in self._cache: 316 | self._cache['whoami'] = self.get( 317 | "%s/verify" % 318 | self._oauth_endpoint) 319 | return self._cache['whoami'] 320 | 321 | def refresh(self): 322 | res = self._authorize( 323 | params={ 324 | "grant_type": "refresh_token", 325 | "refresh_token": self.refresh_token}) 326 | self.token = res['access_token'] 327 | self.expires = int(time.time()) + res['expires_in'] 328 | self._session.headers.update( 329 | {"Authorization": "Bearer %s" % self.token}) 330 | return self # for backwards compatibility 331 | 332 | def get(self, resource, params={}, caching=True): 333 | if int(time.time()) >= self.expires: 334 | self.refresh() 335 | return super(self.__class__, self).get(resource, params, caching) 336 | 337 | 338 | class APIObject(object): 339 | 340 | def __init__(self, parent, connection): 341 | self._dict = {} 342 | self.connection = connection 343 | for k, v in parent.items(): 344 | if isinstance(v, dict): 345 | self._dict[k] = APIObject(v, connection) 346 | elif isinstance(v, list): 347 | self._dict[k] = self._wrap_list(v) 348 | else: 349 | self._dict[k] = v 350 | 351 | def _wrap_list(self, list_): 352 | new = [] 353 | for item in list_: 354 | if isinstance(item, dict): 355 | new.append(APIObject(item, self.connection)) 356 | elif isinstance(item, list): 357 | new.append(self._wrap_list(item)) 358 | else: 359 | new.append(item) 360 | return new 361 | 362 | def __getattr__(self, item): 363 | if item in self._dict: 364 | return self._dict[item] 365 | raise AttributeError(item) 366 | 367 | def __call__(self, **kwargs): 368 | """carries out a CREST request 369 | 370 | __call__ takes two keyword parameters: method and data 371 | 372 | method contains the http request method and defaults to 'get' 373 | but could also be 'post', 'put', or 'delete' 374 | 375 | data contains any arguments that will be passed with the request - 376 | it could be a dictionary which contains parameters 377 | and is passed via the url for 'get' requests and as form-encoded 378 | data for 'post' or 'put' requests. It could also be a string if 379 | another format of data (e.g. via json.dumps()) must be passed in 380 | a 'post' or 'put' request. This parameter has no effect on 381 | 'delete' requests. 382 | """ 383 | 384 | # Caching is now handled by APIConnection 385 | if 'href' in self._dict: 386 | method = kwargs.pop('method', 'get') # default to get: historic behaviour 387 | data = kwargs.pop('data', {}) 388 | caching = kwargs.pop('caching', True) # default caching to true, for get requests 389 | 390 | # retain compatibility with historic method of passing parameters. 391 | # Slightly unsatisfactory - what if data is dict-like but not a dict? 392 | if isinstance(data, dict): 393 | for arg in kwargs: 394 | data[arg] = kwargs[arg] 395 | 396 | if method == 'post': 397 | return APIObject(self.connection.post(self._dict['href'], data=data), self.connection) 398 | elif method == 'put': 399 | return APIObject(self.connection.put(self._dict['href'], data=data), self.connection) 400 | elif method == 'delete': 401 | return APIObject(self.connection.delete(self._dict['href']), self.connection) 402 | elif method == 'get': 403 | return APIObject(self.connection.get(self._dict['href'], 404 | params=data, 405 | caching=caching), 406 | self.connection) 407 | else: 408 | raise UnsupportedHTTPMethodException(method) 409 | else: 410 | return self 411 | 412 | def __str__(self): # pragma: no cover 413 | return self._dict.__str__() 414 | 415 | def __repr__(self): # pragma: no cover 416 | return self._dict.__repr__() 417 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PyCrest 2 | 3 | [![Build Status](https://travis-ci.org/pycrest/PyCrest.svg?branch=master)](https://travis-ci.org/pycrest/PyCrest) 4 | [![Coverage Status](https://coveralls.io/repos/github/pycrest/PyCrest/badge.svg?branch=master)](https://coveralls.io/github/pycrest/PyCrest?branch=master) 5 | 6 | [![Gitter](https://badges.gitter.im/pycrest/PyCrest.svg)](https://gitter.im/pycrest/PyCrest?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 7 | 8 | 9 | ## Contributing 10 | 11 | All help is more than welcome! If you're looking for a way to contribute feel free to raise an issue or create a pull-request and keep PyCrest alive. 12 | 13 | [Read the (old) docs](http://pycrest.readthedocs.org/en/latest/) 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | coveralls 3 | httmock 4 | nose 5 | mock 6 | requests>=2.5.1 7 | future 8 | python-memcached 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='pycrest', 5 | version='0.0.6', 6 | packages=['pycrest'], 7 | url='https://github.com/pycrest/PyCrest', 8 | license='MIT License', 9 | author='Dreae', 10 | author_email='penitenttangentt@gmail.com', 11 | description='Easy interface to the CREST API', 12 | install_requires=['requests'], 13 | test_suite='nose.collector', 14 | classifiers=[ 15 | "Development Status :: 4 - Beta", 16 | "Intended Audience :: Developers", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 2.7", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.2", 21 | "Programming Language :: Python :: 3.3", 22 | "Programming Language :: Python :: 3.4", 23 | "Programming Language :: Python :: Implementation :: PyPy", 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Dreae' 2 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from pycrest.errors import APIException, UnsupportedHTTPMethodException 2 | import unittest 3 | 4 | try: 5 | import __builtin__ 6 | builtins_name = __builtin__.__name__ 7 | except ImportError: 8 | import builtins 9 | builtins_name = builtins.__name__ 10 | 11 | 12 | 13 | class TestAPIException(unittest.TestCase): 14 | 15 | def setUp(self): 16 | pass 17 | 18 | def test_apiexception_data(self): 19 | e = APIException('http://example.com', 205, {'message' : 'example error'}) 20 | 21 | self.assertEqual( 22 | e.url, 23 | 'http://example.com') 24 | 25 | self.assertEqual( 26 | e.status_code, 27 | 205) 28 | 29 | def test_apiexception_str_message(self): 30 | e = APIException('http://example.com', 205, {'message' : 'example error'}) 31 | 32 | self.assertIn( 33 | 'example error', 34 | str(e)) 35 | 36 | self.assertIn( '205', str(e) ) 37 | 38 | def test_apiexception_str_error(self): 39 | e = APIException('http://example.com', 205, {'error' : 'example error'}) 40 | 41 | self.assertIn( 42 | 'example error', 43 | str(e)) 44 | 45 | self.assertIn( '205', str(e) ) 46 | 47 | 48 | 49 | def test_apiexception_str_no_message(self): 50 | e = APIException('http://example.com', 205, {'exception_type' : 'wierd'}) 51 | self.assertIn( '205', str(e) ) 52 | 53 | 54 | class TestUnsupportedHTTPMethodException(unittest.TestCase): 55 | def setUp(self): 56 | pass 57 | 58 | def test_exception_str(self): 59 | e = UnsupportedHTTPMethodException('flatten') 60 | self.assertIn( 'flatten', str(e) ) 61 | 62 | 63 | if __name__ == "__main__": 64 | unittest.main() 65 | -------------------------------------------------------------------------------- /tests/test_pycrest.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Jun 27, 2016 3 | 4 | @author: henk 5 | ''' 6 | import sys 7 | from pycrest.eve import EVE, APIObject 8 | from pycrest.cache import DictCache, DummyCache, APICache, FileCache,\ 9 | MemcachedCache 10 | import httmock 11 | import pycrest 12 | import mock 13 | import errno 14 | from pycrest.errors import APIException, UnsupportedHTTPMethodException 15 | from requests.models import PreparedRequest 16 | from requests.adapters import HTTPAdapter 17 | import unittest 18 | 19 | try: 20 | import __builtin__ 21 | builtins_name = __builtin__.__name__ 22 | except ImportError: 23 | import builtins 24 | builtins_name = builtins.__name__ 25 | 26 | 27 | @httmock.urlmatch( 28 | scheme="https", 29 | netloc=r"(api-sisi\.test)?(crest-tq\.)?eveonline\.com$", 30 | path=r"^/?$") 31 | def root_mock(url, request): 32 | return httmock.response( 33 | status_code=200, 34 | content='''{ 35 | "marketData": { 36 | "href": "https://crest-tq.eveonline.com/market/prices/" 37 | }, 38 | "incursions": { 39 | "href": "https://crest-tq.eveonline.com/incursions/" 40 | }, 41 | "status": { 42 | "eve": "online" 43 | }, 44 | "queryString": { 45 | "href": "https://crest-tq.eveonline.com/queryString/" 46 | }, 47 | "paginatedData": { 48 | "href": "https://crest-tq.eveonline.com/getPage/?page=2" 49 | }, 50 | "writeableEndpoint": { 51 | "href": "https://crest-tq.eveonline.com/writeableMadeUp/" 52 | }, 53 | "list": [ 54 | "item1", 55 | { 56 | "name": "item2" 57 | }, 58 | [ 59 | "item3" 60 | ] 61 | ] 62 | }''', headers={"Cache-Control": "private, max-age=300"}) 63 | 64 | 65 | @httmock.urlmatch( 66 | scheme="https", 67 | netloc=r"(sisilogin\.test)?(login\.)?eveonline\.com$", 68 | path=r"^/oauth/verify/?$") 69 | def verify_mock(url, request): 70 | return { 71 | "status_code": 200, 72 | "content": {"CharacterName": "Foobar"}, 73 | } 74 | 75 | 76 | @httmock.all_requests 77 | def fallback_mock(url, request): 78 | print("No mock for: %s" % request.url) 79 | return httmock.response( 80 | status_code=404, 81 | content='{}') 82 | 83 | 84 | @httmock.urlmatch( 85 | scheme="https", 86 | netloc=r"(sisilogin\.test)?(login\.)?eveonline\.com$", 87 | path=r"^/oauth/?") 88 | def mock_login(url, request): 89 | return httmock.response( 90 | status_code=200, 91 | content='{"access_token": "access_token",' 92 | ' "refresh_token": "refresh_token",' 93 | ' "expires_in": 300}') 94 | 95 | 96 | @httmock.urlmatch( 97 | scheme="https", 98 | netloc=r"(api-sisi\.test)?(crest-tq\.)?eveonline\.com$", 99 | path=r"^/market/prices/?$") 100 | def market_prices_mock(url, request): 101 | return httmock.response( 102 | status_code=200, 103 | content='{"totalCount_str": "10213",' 104 | ' "items": [],' 105 | ' "pageCount": 1,' 106 | ' "pageCount_str": "1",' 107 | ' "totalCount": 10213}') 108 | 109 | @httmock.urlmatch( 110 | scheme="https", 111 | netloc=r"(api-sisi\.test)?(crest-tq\.)?eveonline\.com$", 112 | path=r"^/writeableMadeUp/?$") 113 | def writeable_endpoint_mock(url, request): 114 | return httmock.response( 115 | status_code=200, 116 | content='{}') 117 | 118 | 119 | all_httmocks = [ 120 | root_mock, 121 | mock_login, 122 | verify_mock, 123 | market_prices_mock, 124 | writeable_endpoint_mock, 125 | fallback_mock] 126 | 127 | 128 | class TestEVE(unittest.TestCase): 129 | 130 | def setUp(self): 131 | self.api = EVE( 132 | client_id=1, 133 | redirect_uri='http://localhost:8000/complete/eveonline/') 134 | 135 | def test_endpoint_default(self): 136 | self.assertEqual( 137 | self.api._endpoint, 138 | 'https://crest-tq.eveonline.com/') 139 | self.assertEqual( 140 | self.api._image_server, 141 | 'https://imageserver.eveonline.com/') 142 | self.assertEqual( 143 | self.api._oauth_endpoint, 144 | 'https://login.eveonline.com/oauth') 145 | 146 | def test_endpoint_testing(self): 147 | api = EVE(testing=True) 148 | self.assertEqual( 149 | api._endpoint, 150 | 'https://api-sisi.testeveonline.com/') 151 | # imageserver. is given an 302 redirect to image. on testeveonline.com 152 | # we might just as well keep using the old URL for now 153 | self.assertEqual( 154 | api._image_server, 155 | 'https://image.testeveonline.com/') 156 | self.assertEqual( 157 | api._oauth_endpoint, 158 | 'https://sisilogin.testeveonline.com/oauth') 159 | 160 | def test_auth_uri(self): 161 | self.assertEqual( 162 | self.api.auth_uri(), 163 | 'https://login.eveonline.com/oauth/authorize?response_type=code&r' 164 | 'edirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fcomplete%2Feveonline' 165 | '%2F&client_id=1') 166 | 167 | def test_authorize(self): 168 | 169 | with httmock.HTTMock(*all_httmocks): 170 | self.api.authorize(code='code') 171 | 172 | def test_authorize_non_200(self): 173 | 174 | @httmock.all_requests 175 | def mock_login(url, request): 176 | return httmock.response(status_code=204, 177 | content='{}') 178 | 179 | with httmock.HTTMock(mock_login): 180 | self.assertRaises(APIException, self.api.authorize, code='code') 181 | 182 | def test_refr_authorize(self): 183 | with httmock.HTTMock(*all_httmocks): 184 | self.api.refr_authorize('refresh_token') 185 | 186 | def test_temptoken_authorize(self): 187 | with httmock.HTTMock(*all_httmocks): 188 | self.api.temptoken_authorize(access_token='access_token', 189 | expires_in=300, 190 | refresh_token='refresh_token') 191 | 192 | 193 | class TestAuthedConnection(unittest.TestCase): 194 | 195 | def setUp(self): 196 | with httmock.HTTMock(*all_httmocks): 197 | self.api = EVE() 198 | 199 | with httmock.HTTMock(*all_httmocks): 200 | self.authed = self.api.authorize(code='code') 201 | 202 | def test_call(self): 203 | with httmock.HTTMock(*all_httmocks): 204 | self.authed() 205 | 206 | def test_whoami(self): 207 | with httmock.HTTMock(*all_httmocks): 208 | self.authed.whoami() 209 | 210 | def test_refresh(self): 211 | with httmock.HTTMock(*all_httmocks): 212 | self.authed.refresh() 213 | 214 | def test_refresh_on_get(self): 215 | self.authed.expires = 0 216 | with httmock.HTTMock(*all_httmocks): 217 | self.authed() 218 | 219 | 220 | class TestAPIConnection(unittest.TestCase): 221 | 222 | def setUp(self): 223 | self.api = EVE() 224 | 225 | def test_user_agent(self): 226 | @httmock.all_requests 227 | def default_user_agent(url, request): 228 | user_agent = request.headers.get('User-Agent', None) 229 | self.assertEqual( 230 | user_agent, 'PyCrest/{0} +https://github.com/pycrest/PyCrest' 231 | .format(pycrest.version)) 232 | 233 | with httmock.HTTMock(default_user_agent): 234 | EVE() 235 | 236 | @httmock.all_requests 237 | def customer_user_agent(url, request): 238 | user_agent = request.headers.get('User-Agent', None) 239 | self.assertEqual( 240 | user_agent, 241 | 'PyCrest-Testing/{0} +https://github.com/pycrest/PyCrest' 242 | .format(pycrest.version)) 243 | 244 | with httmock.HTTMock(customer_user_agent): 245 | EVE(user_agent='PyCrest-Testing/{0} +https://github.com/pycrest/P' 246 | 'yCrest'.format(pycrest.version)) 247 | 248 | def test_headers(self): 249 | 250 | # Check default header 251 | @httmock.all_requests 252 | def check_default_headers(url, request): 253 | self.assertNotIn('PyCrest-Testing', request.headers) 254 | 255 | with httmock.HTTMock(check_default_headers): 256 | EVE() 257 | 258 | # Check custom header 259 | def check_custom_headers(url, request): 260 | self.assertIn('PyCrest-Testing', request.headers) 261 | 262 | with httmock.HTTMock(check_custom_headers): 263 | EVE(additional_headers={'PyCrest-Testing': True}) 264 | 265 | def test_custom_transport_adapter(self): 266 | """ Check if the transport adapter is the one expected (especially if we set it) """ 267 | class TestHttpAdapter(HTTPAdapter): 268 | def __init__(self, *args, **kwargs): 269 | super(TestHttpAdapter, self).__init__(*args, **kwargs) 270 | 271 | class FakeHttpAdapter(object): 272 | def __init__(self, *args, **kwargs): 273 | pass 274 | 275 | eve = EVE() 276 | self.assertTrue(isinstance(eve._session.get_adapter('http://'), HTTPAdapter)) 277 | self.assertTrue(isinstance(eve._session.get_adapter('https://'), HTTPAdapter)) 278 | self.assertFalse(isinstance(eve._session.get_adapter('http://'), TestHttpAdapter)) 279 | self.assertFalse(isinstance(eve._session.get_adapter('https://'), TestHttpAdapter)) 280 | 281 | eve = EVE(transport_adapter=TestHttpAdapter()) 282 | self.assertTrue(isinstance(eve._session.get_adapter('http://'), TestHttpAdapter)) 283 | self.assertTrue(isinstance(eve._session.get_adapter('https://'), TestHttpAdapter)) 284 | 285 | # check that the wrong httpadapter is not used 286 | eve = EVE(transport_adapter=FakeHttpAdapter()) 287 | self.assertTrue(isinstance(eve._session.get_adapter('http://'), HTTPAdapter)) 288 | self.assertFalse(isinstance(eve._session.get_adapter('http://'), FakeHttpAdapter)) 289 | 290 | eve = EVE(transport_adapter='') 291 | self.assertTrue(isinstance(eve._session.get_adapter('http://'), HTTPAdapter)) 292 | 293 | 294 | def test_default_cache(self): 295 | self.assertTrue(isinstance(self.api.cache, DictCache)) 296 | 297 | def test_no_cache(self): 298 | eve = EVE(cache=None) 299 | self.assertTrue(isinstance(eve.cache, DummyCache)) 300 | 301 | def test_implements_apiobject(self): 302 | class CustomCache(object): 303 | pass 304 | with self.assertRaises(ValueError): 305 | eve = EVE(cache=CustomCache) 306 | 307 | def test_apicache(self): 308 | eve = EVE(cache=DictCache()) 309 | self.assertTrue(isinstance(eve.cache, DictCache)) 310 | 311 | 312 | @mock.patch('os.path.isdir', return_value=False) 313 | @mock.patch('os.mkdir') 314 | def test_file_cache(self, mkdir_function, isdir_function): 315 | file_cache = FileCache(path=TestFileCache.DIR) 316 | eve = EVE(cache=file_cache) 317 | self.assertEqual(file_cache.path, TestFileCache.DIR) 318 | self.assertTrue(isinstance(eve.cache, FileCache)) 319 | 320 | 321 | def test_default_url(self): 322 | 323 | @httmock.all_requests 324 | def root_mock(url, request): 325 | self.assertEqual(url.path, '/') 326 | self.assertEqual(url.query, '') 327 | return {'status_code': 200, 328 | 'content': '{}'.encode('utf-8')} 329 | 330 | with httmock.HTTMock(root_mock): 331 | self.api() 332 | 333 | def test_parse_parameters_url(self): 334 | 335 | @httmock.all_requests 336 | def key_mock(url, request): 337 | self.assertEqual(url.path, '/') 338 | self.assertEqual(url.query, 'key=value1') 339 | return {'status_code': 200, 340 | 'content': '{}'.encode('utf-8')} 341 | 342 | with httmock.HTTMock(key_mock): 343 | self.api.get('https://crest-tq.eveonline.com/?key=value1') 344 | 345 | def test_parse_parameters_override(self): 346 | 347 | @httmock.all_requests 348 | def key_mock(url, request): 349 | self.assertEqual(url.path, '/') 350 | self.assertEqual(url.query, 'key=value2') 351 | return {'status_code': 200, 352 | 'content': '{}'.encode('utf-8')} 353 | 354 | with httmock.HTTMock(key_mock): 355 | self.api.get( 356 | 'https://crest-tq.eveonline.com/?key=value1', 357 | dict(key='value2')) 358 | 359 | def test_cache_hit(self): 360 | @httmock.all_requests 361 | def prime_cache(url, request): 362 | headers = {'content-type': 'application/json', 363 | 'Cache-Control': 'max-age=300;'} 364 | return httmock.response(200, '{}'.encode('utf-8'), headers) 365 | 366 | with httmock.HTTMock(prime_cache): 367 | self.assertEqual(self.api()._dict, {}) 368 | 369 | @httmock.all_requests 370 | def cached_request(url, request): 371 | raise RuntimeError( 372 | 'A cached request should never yield a HTTP request') 373 | 374 | with httmock.HTTMock(cached_request): 375 | self.api._data = None 376 | self.assertEqual(self.api()._dict, {}) 377 | 378 | def test_caching_arg_hit(self): 379 | """ Test the caching argument for ApiConnection and ApiObject __call__() """ 380 | 381 | @httmock.urlmatch( 382 | scheme="https", 383 | netloc=r"(api-sisi\.test)?(crest-tq\.)?eveonline\.com$", 384 | path=r"^/market/prices/?$") 385 | def market_prices_cached_mock(url, request): 386 | headers = {'content-type': 'application/json', 387 | 'Cache-Control': 'max-age=300;'} 388 | return httmock.response( 389 | status_code=200, 390 | headers=headers, 391 | content='{}'.encode('utf-8')) 392 | 393 | with httmock.HTTMock(root_mock, market_prices_cached_mock): 394 | self.assertEqual(self.api.cache._dict, {}) 395 | 396 | self.api(caching=False) 397 | self.assertEqual(self.api.cache._dict, {}) 398 | 399 | self.api._data = None 400 | self.api() 401 | self.assertEqual(len(self.api.cache._dict), 1) 402 | 403 | self.assertEqual(self.api().marketData(caching=False)._dict, {}) 404 | self.assertEqual(len(self.api.cache._dict), 1) 405 | 406 | self.assertEqual(self.api().marketData()._dict, {}) 407 | self.assertEqual(len(self.api.cache._dict), 2) 408 | 409 | 410 | def test_cache_invalidate(self): 411 | @httmock.all_requests 412 | def prime_cache(url, request): 413 | headers = {'content-type': 'application/json', 414 | 'Cache-Control': 'max-age=300;'} 415 | return httmock.response( 416 | 200, '{"cached": true}'.encode('utf-8'), headers) 417 | 418 | # Prime cache and force the expiration 419 | with httmock.HTTMock(prime_cache): 420 | self.api() 421 | # Nuke _data so the .get() is actually being called the next call 422 | self.api._data = None 423 | for key in self.api.cache._dict: 424 | # Make sure the cache is concidered 'expired' 425 | self.api.cache._dict[key]['expires'] = 0 426 | 427 | @httmock.all_requests 428 | def expired_request(url, request): 429 | self.assertTrue(isinstance(request, PreparedRequest)) 430 | return httmock.response(200, '{}'.encode('utf-8')) 431 | 432 | with httmock.HTTMock(expired_request): 433 | self.api() 434 | 435 | def test_non_http_200(self): 436 | 437 | @httmock.all_requests 438 | def non_http_200(url, request): 439 | return {'status_code': 404, 'content' : {'message' : 'not found'}} 440 | 441 | with httmock.HTTMock(non_http_200): 442 | self.assertRaises(APIException, self.api) 443 | 444 | def test_get_expires(self): 445 | # No header at all 446 | r = httmock.response(200, '{}'.encode('utf-8')) 447 | self.assertEqual(self.api._get_expires(r), 0) 448 | 449 | # Cache-Control header with no-cache 450 | r = httmock.response(status_code=200, 451 | content='{}'.encode('utf-8'), 452 | headers={'Cache-Control': 'no-cache'}) 453 | self.assertEqual(self.api._get_expires(r), 0) 454 | 455 | # Cache-Control header with no-store 456 | r = httmock.response(status_code=200, 457 | content='{}'.encode('utf-8'), 458 | headers={'Cache-Control': 'no-store'}) 459 | self.assertEqual(self.api._get_expires(r), 0) 460 | 461 | # Cache-Control header with wrong content 462 | r = httmock.response(status_code=200, 463 | content='{}'.encode('utf-8'), 464 | headers={'Cache-Control': 'no-way'}) 465 | self.assertEqual(self.api._get_expires(r), 0) 466 | 467 | # Cache-Control header with max-age=300 468 | r = httmock.response(status_code=200, 469 | content='{}'.encode('utf-8'), 470 | headers={'Cache-Control': 'max-age=300'}) 471 | self.assertEqual(self.api._get_expires(r), 300) 472 | 473 | def test_session_mock(self): 474 | # Check default header 475 | @httmock.all_requests 476 | def expired_request(url, request): 477 | print(url) 478 | print(request) 479 | self.assertTrue(isinstance(request, PreparedRequest)) 480 | return httmock.response(200, '{}'.encode('utf-8')) 481 | 482 | with httmock.HTTMock(expired_request): 483 | self.api() 484 | 485 | 486 | class TestAPICache(unittest.TestCase): 487 | 488 | def setUp(self): 489 | self.c = APICache() 490 | 491 | def test_put(self): 492 | self.assertRaises(NotImplementedError, self.c.get, 'key') 493 | 494 | def test_get(self): 495 | self.assertRaises(NotImplementedError, self.c.put, 'key', 'val') 496 | 497 | def test_invalidate(self): 498 | self.assertRaises(NotImplementedError, self.c.invalidate, 'key') 499 | 500 | 501 | class TestDictCache(unittest.TestCase): 502 | 503 | def setUp(self): 504 | self.c = DictCache() 505 | self.c.put('key', True) 506 | 507 | def test_put(self): 508 | self.assertEqual(self.c._dict['key'], True) 509 | 510 | def test_get(self): 511 | self.assertEqual(self.c.get('key'), True) 512 | 513 | def test_invalidate(self): 514 | self.c.invalidate('key') 515 | self.assertIsNone(self.c.get('key')) 516 | 517 | def test_cache_dir(self): 518 | pass 519 | 520 | class TestDummyCache(unittest.TestCase): 521 | 522 | def setUp(self): 523 | self.c = DummyCache() 524 | self.c.put('never_stored', True) 525 | 526 | def test_put(self): 527 | self.assertNotIn('never_stored', self.c._dict) 528 | 529 | def test_get(self): 530 | self.assertEqual(self.c.get('never_stored'), None) 531 | 532 | def test_invalidate(self): 533 | self.c.invalidate('never_stored') 534 | self.assertIsNone(self.c.get('never_stored')) 535 | 536 | 537 | class TestFileCache(unittest.TestCase): 538 | ''' 539 | Class for testing the filecache 540 | 541 | TODO: Debug wth this test is creating an SSL connection 542 | ''' 543 | 544 | DIR = '/tmp/TestFileCache' 545 | 546 | @mock.patch('os.path.isdir') 547 | @mock.patch('os.mkdir') 548 | @mock.patch('{0}.open'.format(builtins_name)) 549 | def setUp(self, open_function, mkdir_function, isdir_function): 550 | self.c = FileCache(TestFileCache.DIR) 551 | self.c.put('key', 'value') 552 | 553 | @mock.patch('os.path.isdir', return_value=False) 554 | @mock.patch('os.mkdir') 555 | def test_init(self, mkdir_function, isdir_function): 556 | c = FileCache(TestFileCache.DIR) 557 | 558 | # Ensure path has been set 559 | self.assertEqual(c.path, TestFileCache.DIR) 560 | 561 | # Ensure we checked if the dir was already there 562 | args, kwargs = isdir_function.call_args 563 | self.assertEqual((TestFileCache.DIR,), args) 564 | 565 | # Ensure we called mkdir with the right args 566 | args, kwargs = mkdir_function.call_args 567 | self.assertEqual((TestFileCache.DIR, 0o700), args) 568 | 569 | # @unittest.skip("https://github.com/pycrest/PyCrest/issues/30") 570 | # def test_getpath(self): 571 | # self.assertEqual(self.c._getpath('key'), 572 | # os.path.join(TestFileCache.DIR, 573 | # '1140801208126482496.cache')) 574 | 575 | def test_get_uncached(self): 576 | # Check non-existant key 577 | self.assertIsNone(self.c.get('nope')) 578 | 579 | @mock.patch('builtins.open') 580 | def test_get_cached(self, open_function): 581 | self.assertEqual(self.c.get('key'), 'value') 582 | 583 | @unittest.skipIf( 584 | sys.version_info < ( 585 | 3,), 'Python 2.x uses a diffrent protocol') 586 | @mock.patch('{0}.open'.format(builtins_name), mock.mock_open( 587 | read_data=b'x\x9ck`\ne-K\xcc)M-d\xd0\x03\x00\x17\xde\x03\x99')) 588 | def test_get_cached_file_py3(self): 589 | del(self.c._cache['key']) 590 | self.assertEqual(self.c.get('key'), 'value') 591 | 592 | @unittest.skipIf( 593 | sys.version_info > ( 594 | 3,), 'Python 3.x uses a diffrent protocol') 595 | @mock.patch('{0}.open'.format(builtins_name), mock.mock_open( 596 | read_data='x\x9ck`\ne-K\xcc)M-d\xd0\x03\x00\x17\xde\x03\x99')) 597 | def test_get_cached_file_py2(self): 598 | del(self.c._cache['key']) 599 | self.assertEqual(self.c.get('key'), 'value') 600 | 601 | @mock.patch('os.unlink') 602 | def test_invalidate(self, unlink_function): 603 | # Make sure our key is here in the first place 604 | self.assertIn('key', self.c._cache) 605 | 606 | # Unset the key and ensure unlink() was called 607 | self.c.invalidate('key') 608 | self.assertTrue(unlink_function.called) 609 | # TODO: When paths are predictable check the args 610 | # See https://github.com/pycrest/PyCrest/issues/30 611 | 612 | @mock.patch( 613 | 'os.unlink', 614 | side_effect=OSError( 615 | errno.ENOENT, 616 | 'No such file or directory')) 617 | def test_unlink_exception(self, unlink_function): 618 | self.assertIsNone(self.c.invalidate('key')) 619 | 620 | 621 | class TestMemcachedCache(unittest.TestCase): 622 | '''A very basic MemcachedCache TestCase 623 | 624 | Primairy goal of this unittest is to get the coverage up 625 | to spec. Should probably make use of `mockcache` in the future''' 626 | 627 | memcache_mock = mock.MagicMock() 628 | memcache_mock.get.return_value = 'value' 629 | 630 | @mock.patch('memcache.Client', return_value=memcache_mock) 631 | def setUp(self, mock_memcache): 632 | self.c = MemcachedCache(['127.0.0.1:11211']) 633 | 634 | def test_put(self): 635 | self.c.put('key', 'value') 636 | 637 | def test_get(self): 638 | self.assertEqual(self.c.get('key'), 'value') 639 | 640 | def test_invalidate(self): 641 | self.c.invalidate('key') 642 | 643 | 644 | class TestAPIObject(unittest.TestCase): 645 | 646 | def setUp(self): 647 | self.api = EVE() 648 | with httmock.HTTMock(*all_httmocks): 649 | self.api() 650 | 651 | def test_getattr(self): 652 | res = self.api().list 653 | self.assertEqual(res[0], 'item1') 654 | 655 | def test_getattr_exception(self): 656 | self.assertRaises( 657 | AttributeError, 658 | getattr, 659 | self.api, 660 | "invalid_property") 661 | 662 | def test_call(self): 663 | with httmock.HTTMock(*all_httmocks): 664 | res = self.api().list 665 | self.assertTrue(isinstance(res, list)) 666 | 667 | def test_call_href(self): 668 | with httmock.HTTMock(*all_httmocks): 669 | res = self.api().marketData() 670 | self.assertTrue(isinstance(res, APIObject)) 671 | 672 | def test_call_post(self): 673 | with httmock.HTTMock(*all_httmocks): 674 | res = self.api().writeableEndpoint(method='post') 675 | self.assertTrue(isinstance(res, APIObject)) 676 | 677 | def test_call_put(self): 678 | with httmock.HTTMock(*all_httmocks): 679 | res = self.api().writeableEndpoint(method='put') 680 | self.assertTrue(isinstance(res, APIObject)) 681 | 682 | def test_call_delete(self): 683 | with httmock.HTTMock(*all_httmocks): 684 | res = self.api().writeableEndpoint(method='delete') 685 | self.assertTrue(isinstance(res, APIObject)) 686 | 687 | def test_non_http_200_201_post(self): 688 | 689 | @httmock.all_requests 690 | def non_http_200(url, request): 691 | return {'status_code': 404, 'content' : {'message' : 'not found'}} 692 | 693 | with httmock.HTTMock(non_http_200): 694 | self.assertRaises(APIException, self.api.writeableEndpoint, method='post') 695 | 696 | def test_non_http_200_put(self): 697 | 698 | @httmock.all_requests 699 | def non_http_200(url, request): 700 | return {'status_code': 201, 'content' : {'message' : 'created new object'}} 701 | 702 | with httmock.HTTMock(non_http_200): 703 | self.assertRaises(APIException, self.api.writeableEndpoint, method='put') 704 | 705 | def test_non_http_200_delete(self): 706 | 707 | @httmock.all_requests 708 | def non_http_200(url, request): 709 | return {'status_code': 201, 'content' : {'message' : 'created new object'}} 710 | 711 | with httmock.HTTMock(non_http_200): 712 | self.assertRaises(APIException, self.api.writeableEndpoint, method='delete') 713 | 714 | #201 received from successful contact creation via POST 715 | def test_http_201_post(self): 716 | @httmock.all_requests 717 | def http_201(url, request): 718 | return {'status_code': 201, 'content' : {'message' : 'created new object'}} 719 | 720 | with httmock.HTTMock(http_201): 721 | res = self.api.writeableEndpoint(method='post') 722 | self.assertTrue(isinstance(res, APIObject)) 723 | 724 | 725 | def test_double_call_self(self): 726 | with httmock.HTTMock(*all_httmocks): 727 | r1 = self.api() 728 | r2 = r1() 729 | self.assertEqual(r1, r2) 730 | 731 | def test_deprecated_parameter_passing(self): 732 | with httmock.HTTMock(*all_httmocks): 733 | res = self.api.writeableEndpoint(arg1='val1', arg2='val2') 734 | 735 | self.assertTrue(isinstance(res, APIObject)) 736 | 737 | def test_string_parameter_passing(self): 738 | with httmock.HTTMock(*all_httmocks): 739 | res = self.api.writeableEndpoint(method='post', data='some (json?) data') 740 | 741 | self.assertTrue(isinstance(res, APIObject)) 742 | 743 | def test_dict_parameter_passing(self): 744 | with httmock.HTTMock(*all_httmocks): 745 | res = self.api.writeableEndpoint(data={'arg1' : 'val1' }) 746 | 747 | self.assertTrue(isinstance(res, APIObject)) 748 | 749 | def test_unhandled_http_method_exception(self): 750 | with httmock.HTTMock(*all_httmocks): 751 | self.assertRaises(UnsupportedHTTPMethodException, self.api.writeableEndpoint, method='snip') #made-up http method 752 | 753 | if __name__ == "__main__": 754 | unittest.main() 755 | --------------------------------------------------------------------------------