friends-0.2.0+14.04.20140217.1/0000755000015201777760000000000012300444701015672 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/0000755000015201777760000000000012300444701017332 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/ChangeLog0000644000015201777760000000000012300444435021076 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/configure.ac0000644000015201777760000000111012300444435021615 0ustar pbusernogroup00000000000000AC_INIT([friends-service], 0.1) AM_INIT_AUTOMAKE([no-dist-gzip dist-bzip2]) AC_CONFIG_HEADERS([config.h]) AM_SILENT_RULES([yes]) AM_MAINTAINER_MODE AM_PROG_VALAC([0.16]) AC_PROG_CC AM_PROG_CC_STDC AC_PROG_INSTALL ########################### # GSETTINGS ########################### GLIB_GSETTINGS DEE_REQUIRED=1.0.0 PKG_CHECK_MODULES(BASE, libaccounts-glib gio-2.0 dee-1.0 >= $DEE_REQUIRED) AC_SUBST(BASE_CFLAGS) AC_SUBST(BASE_LIBS) AC_CONFIG_FILES([ Makefile src/Makefile data/Makefile ]) AC_OUTPUT friends-0.2.0+14.04.20140217.1/service/NEWS0000644000015201777760000000000012300444435020023 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/README0000644000015201777760000000000012300444435020204 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/autogen.sh0000755000015201777760000000024412300444435021337 0ustar pbusernogroup00000000000000#!/bin/sh PKG_NAME="friends-service" which gnome-autogen.sh || { echo "You need gnome-common from GNOME SVN" exit 1 } USE_GNOME2_MACROS=1 \ . gnome-autogen.sh friends-0.2.0+14.04.20140217.1/service/AUTHORS0000644000015201777760000000000012300444435020374 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/Makefile.am0000644000015201777760000000007012300444435021367 0ustar pbusernogroup00000000000000SUBDIRS = src data dist_noinst_SCRIPTS = \ autogen.sh friends-0.2.0+14.04.20140217.1/service/src/0000755000015201777760000000000012300444701020121 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/src/service.vala0000644000015201777760000002016412300444435022435 0ustar pbusernogroup00000000000000/* * Copyright (C) 2013 Canonical Ltd. * * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License version 3, as published * by the Free Software Foundation. * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranties of * MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR * PURPOSE. See the GNU General Public License for more details. * You should have received a copy of the GNU General Public License along * with this program. If not, see . * * Authored by Ken VanDine */ using Ag; [DBus (name = "com.canonical.Friends.Dispatcher")] private interface Dispatcher : GLib.Object { public abstract void Refresh () throws GLib.IOError; public abstract async void Do ( string action, string account_id, string message_id, out string result ) throws GLib.IOError; } [DBus (name = "com.canonical.Friends.Service")] public class Master : Object { private Dee.Model model; private Dee.SharedModel shared_model; private unowned Dee.ResourceManager resources; private Ag.Manager acct_manager; private Dispatcher dispatcher; public int interval { get; set; } public Master () { acct_manager = new Ag.Manager.for_service_type ("microblogging"); acct_manager.account_deleted.connect ((manager, account_id) => { debug ("Account %u deleted from UOA, purging...", account_id); uint purged = 0; uint rows = model.get_n_rows (); // Destructively iterate over the Model from back to // front; I know "i < rows" looks kinda goofy here, // but what's happening is that i is unsigned, so once // it hits 0, i-- will overflow to a very large // number, and then "i < rows" will fail, stopping the // iteration at index 0. for (uint i = rows - 1; i < rows; i--) { var itr = model.get_iter_at_row (i); if (model.get_uint64 (itr, 1) == account_id) { model.remove (itr); purged++; } } debug ("Purged %u rows.", purged); } ); acct_manager.account_created.connect ((manager, account_id) => { debug ("Account %u created from UOA, refreshing", account_id); try { dispatcher.Refresh (); } catch (IOError e) { warning ("Failed to refresh - %s", e.message); } } ); resources = Dee.ResourceManager.get_default (); model = new Dee.SequenceModel (); Dee.SequenceModel? _m = null; try { _m = resources.load ("com.canonical.Friends.Streams") as Dee.SequenceModel; } catch (Error e) { debug ("Failed to load model from resource manager: %s", e.message); } string[] SCHEMA = {}; var file = FileStream.open("/usr/share/friends/model-schema.csv", "r"); string line = null; while (true) { line = file.read_line(); if (line == null) break; SCHEMA += line.split(",")[1]; } debug ("Found %u schema columns.", SCHEMA.length); bool schemaReset = false; if (_m is Dee.Model && !schemaReset) { debug ("Got a valid model"); // Compare columns from cached model's schema string[] _SCHEMA = _m.get_schema (); if (_SCHEMA.length != SCHEMA.length) schemaReset = true; else { for (int i=0; i < _SCHEMA.length; i++) { if (_SCHEMA[i] != SCHEMA[i]) { debug ("SCHEMA MISMATCH"); schemaReset = true; } } } if (!schemaReset) model = _m; else { debug ("Setting schema"); model.set_schema_full (SCHEMA); } } else { debug ("Setting schema for a new model"); model.set_schema_full (SCHEMA); } var settings = new Settings ("com.canonical.friends"); settings.bind("interval", this, "interval", 0); if (model is Dee.Model) { debug ("Model with %u rows", model.get_n_rows()); Dee.Peer peer = Object.new (typeof(Dee.Peer), swarm_name: "com.canonical.Friends.Streams", swarm_owner: true, null) as Dee.Peer; peer.peer_found.connect((peername) => { debug ("new peer: %s", peername); }); peer.peer_lost.connect((peername) => { debug ("lost peer: %s", peername); }); Dee.SharedModel shared_model = Object.new (typeof(Dee.SharedModel), peer: peer, back_end: model, null) as Dee.SharedModel; debug ("swarm leader: %s", peer.get_swarm_leader()); shared_model.notify["synchronized"].connect(() => { if (shared_model.is_synchronized()) { debug ("SYNCHRONIZED"); shared_model.flush_revision_queue(); } if (shared_model.is_leader()) debug ("LEADER"); else debug ("NOT LEADER"); }); Timeout.add_seconds (interval * 60, () => { shared_model.flush_revision_queue(); debug ("Storing model with %u rows", model.get_n_rows()); resources.store ((Dee.SequenceModel)model, "com.canonical.Friends.Streams"); return true; }); } Bus.get_proxy.begin(BusType.SESSION, "com.canonical.Friends.Dispatcher", "/com/canonical/friends/Dispatcher", DBusProxyFlags.NONE, null, on_proxy_cb); } private void on_proxy_cb (GLib.Object? obj, GLib.AsyncResult res) { try { dispatcher = Bus.get_proxy.end(res); // Timeout.add_seconds (120, fetch_contacts); // LP#1214639 var ret = on_refresh (); } catch (IOError e) { warning (e.message); } } bool on_refresh () { debug ("Interval is %d", interval); // By default, this happens immediately on startup, and then // every 15 minutes thereafter. Timeout.add_seconds ((interval * 60), on_refresh); try { dispatcher.Refresh (); } catch (IOError e) { warning ("Failed to refresh - %s", e.message); } return false; } /* Temporarily disabled as requested by Bill Filler, LP#1214639 * Most likely we'll need a gsetting to turn this off or on, * but just disable it for now. bool fetch_contacts () { debug ("Fetching contacts..."); // By default, this happens 2 minutes after startup, and then // every 24 hours thereafter. Timeout.add_seconds (86400, fetch_contacts); try { dispatcher.Do ("contacts", "", ""); } catch (IOError e) { warning ("Failed to fetch contacts - %s", e.message); } return false; } */ } void on_bus_aquired (DBusConnection conn) { try { conn.register_object ("/com/canonical/friends/Service", new Master ()); } catch (IOError e) { stderr.printf ("Could not register service\n"); } } public static int main (string[] args) { Bus.own_name (BusType.SESSION, "com.canonical.Friends.Service", BusNameOwnerFlags.NONE, on_bus_aquired, () => {}, () => stderr.printf ("Could not aquire name\n")); new MainLoop().run(); return 0; } friends-0.2.0+14.04.20140217.1/service/src/Makefile.am0000644000015201777760000000033512300444435022162 0ustar pbusernogroup00000000000000bin_PROGRAMS = \ friends-service INCLUDES = \ $(BASE_CFLAGS) VALAFLAGS = \ --pkg libaccounts-glib \ --pkg dee-1.0 \ --pkg gio-2.0 friends_service_LDADD = \ $(BASE_LIBS) friends_service_SOURCES = \ service.vala friends-0.2.0+14.04.20140217.1/service/data/0000755000015201777760000000000012300444701020243 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/service/data/com.canonical.Friends.Service.service.in0000644000015201777760000000012112300444435027724 0ustar pbusernogroup00000000000000[D-BUS Service] Name=com.canonical.Friends.Service Exec=@bindir@/friends-service friends-0.2.0+14.04.20140217.1/service/data/Makefile.am0000644000015201777760000000047412300444435022310 0ustar pbusernogroup00000000000000dbus_servicesdir = $(datadir)/dbus-1/services service_in_files = com.canonical.Friends.Service.service.in dbus_services_DATA = $(service_in_files:.service.in=.service) %.service: %.service.in sed -e "s|\@bindir\@|$(bindir)|" $< > $@ EXTRA_DIST = \ $(service_in_files) CLEANFILES = \ $(dbus_services_DATA) friends-0.2.0+14.04.20140217.1/setup.py0000644000015201777760000000304112300444435017406 0ustar pbusernogroup00000000000000# friends -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import sys from setuptools import setup, find_packages if sys.version_info[:2] < (3, 2): raise RuntimeError('Python 3.2 or newer required') setup( name='friends', version='0.1', packages=find_packages(), include_package_data=True, package_data = { 'friends.service.templates': ['*.service.in'], 'friends.tests.data': ['*.dat'], }, data_files = [ ('/usr/share/glib-2.0/schemas', ['data/com.canonical.friends.gschema.xml']), ('/usr/share/friends', ['data/model-schema.csv']), ], entry_points = { 'console_scripts': ['friends-dispatcher = friends.main:main'], 'distutils.commands': [ 'install_service_files = ' 'friends.utils.install:install_service_files', ], }, test_requires = [ 'mock', ], ) friends-0.2.0+14.04.20140217.1/tools/0000755000015201777760000000000012300444701017032 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/tools/debug_live.py0000755000015201777760000000414512300444435021524 0ustar pbusernogroup00000000000000#!/usr/bin/env python3 """Usage: ./tools/debug_live.py PROTOCOL OPERATION [OPTIONS] Where PROTOCOL is a protocol supported by Friends, such as 'twitter', OPERATION is an instance method defined in that protocol's class, and OPTIONS are whatever arguments you'd like to pass to that method (if any), such as message id's or a status message. Examples: ./tools/debug_live.py twitter home ./tools/debug_live.py twitter send 'Hello, world!' This tool is provided to aid with rapid feedback of changes made to the friends source tree, and as such is designed to be run from the same directory that contains 'setup.py'. It is not intended for use with an installed friends package. """ import sys import logging sys.path.insert(0, '.') # Ignore system-installed schema. from friends.tests.mocks import SCHEMA from gi.repository import GLib from friends.utils.logging import initialize # Print all logs for debugging purposes initialize(debug=True, console=True) from friends.service.dispatcher import ManageTimers from friends.utils.account import find_accounts from friends.utils.base import initialize_caches, _OperationThread from friends.utils.model import Model log = logging.getLogger('friends.debug_live') loop = GLib.MainLoop() def row_added(model, itr): row = model.get_row(itr) print(row) log.info('ROWS: {}'.format(len(model))) print() def setup(model, signal, protocol, args): _OperationThread.shutdown = loop.quit initialize_caches() Model.connect('row-added', row_added) found = False for account in find_accounts().values(): if account.protocol._name == protocol.lower(): found = True with ManageTimers() as cm: account.protocol(*args) if not found: log.error('No {} found in Ubuntu Online Accounts!'.format(protocol)) loop.quit() if __name__ == '__main__': if len(sys.argv) < 3: sys.exit(__doc__) protocol = sys.argv[1] args = sys.argv[2:] ManageTimers.callback = loop.quit ManageTimers.timeout = 5 Model.connect('notify::synchronized', setup, protocol, args) loop.run() friends-0.2.0+14.04.20140217.1/tools/debug_slave.py0000755000015201777760000000176112300444435021700 0ustar pbusernogroup00000000000000#!/usr/bin/env python3 """Usage: ./tools/debug_slave.py Run this script in parallel with debug_live.py to watch changes to the Friends database model as it is updated over dbus. It is not intended for use with an installed friends package. """ import sys sys.path.insert(0, '.') from gi.repository import Dee from gi.repository import GLib from friends.tests.mocks import SCHEMA class Slave: def __init__(self): model_name = 'com.canonical.Friends.Streams' print('Joining model ' + model_name) self.model = Dee.SharedModel.new(model_name) self.model.connect('row-added', self.on_row_added) self.model.connect('row-changed', self.on_row_added) def on_row_added(self, model, itr): row = self.model.get_row(itr) print('\n' * 5) for i, col in enumerate(row): print('{:12}: {}'.format(SCHEMA.NAMES[i], col)) print('ROWS: ', len(self.model)) if __name__ == '__main__': s = Slave() GLib.MainLoop().run() friends-0.2.0+14.04.20140217.1/docs/0000755000015201777760000000000012300444701016622 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/docs/_static/0000755000015201777760000000000012300444701020250 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/docs/index.rst0000644000015201777760000000241012300444435020464 0ustar pbusernogroup00000000000000.. friends documentation master file, created by sphinx-quickstart on Mon Apr 15 19:32:21 2013. friends -- send & receive messages from any social network Copyright (C) 2013 Canonical Ltd This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . The Superclass of all Protocols =============================== .. automodule:: friends.utils.base :members: :special-members: :private-members: Dispatcher ========== .. automodule:: friends.service.dispatcher :members: :special-members: :private-members: libsoup wrappers ================ .. automodule:: friends.utils.http :members: :special-members: :private-members: Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` friends-0.2.0+14.04.20140217.1/docs/Makefile0000644000015201777760000001270012300444435020266 0ustar pbusernogroup00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/friends.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/friends.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/friends" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/friends" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." friends-0.2.0+14.04.20140217.1/docs/_build/0000755000015201777760000000000012300444701020060 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/docs/conf.py0000644000015201777760000002062712300444435020134 0ustar pbusernogroup00000000000000#!/usr/bin/env python3 # friends -- send & receive messages from any social network # Copyright (C) 2013 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # friends documentation build configuration file, created by # sphinx-quickstart on Mon Apr 15 19:32:21 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys, os # If extensions (or modules to document with autodoc) are in another 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('.')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #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.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'friends' copyright = '2013, Canonical Ltd' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '0.2' # The full version, including alpha/beta/rc tags. release = '0.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #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'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #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 # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- 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' # 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 = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #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 # 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 # 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'] # 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' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is 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 = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'friendsdoc' # -- Options for LaTeX output -------------------------------------------------- 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': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'friends.tex', 'Friends Documentation', 'Robert Bruce Park, Ken VanDine, Barry Warsaw', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'friends', 'Friends Documentation', ['Robert Bruce Park, Ken VanDine, Barry Warsaw'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'friends', 'Friends Documentation', 'Robert Bruce Park, Ken VanDine, Barry Warsaw', 'friends', 'Social networking integration for linux systems.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' friends-0.2.0+14.04.20140217.1/docs/_templates/0000755000015201777760000000000012300444701020757 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/AUTHORS0000644000015201777760000000024312300444435016745 0ustar pbusernogroup00000000000000Robert Bruce Park Barry Warsaw Ken VanDine Conor Curran friends-0.2.0+14.04.20140217.1/friends/0000755000015201777760000000000012300444701017324 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/__init__.py0000644000015201777760000000000012300444435021427 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/errors.py0000644000015201777760000000410712300444435021220 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Internal Friends exceptions.""" __all__ = [ 'AuthorizationError', 'FriendsError', 'UnsupportedProtocolError', ] try: from contextlib import ignored except ImportError: from contextlib import contextmanager # This is a fun toy from Python 3.4, but I want it NOW!! @contextmanager def ignored(*exceptions): """Context manager to ignore specifed exceptions. with ignored(OSError): os.remove(somefile) Thanks to Raymond Hettinger for this. """ try: yield except exceptions: pass class FriendsError(Exception): """Base class for all internal Friends exceptions.""" class AuthorizationError(FriendsError): """Backend service authorization errors.""" def __init__(self, account, message): self.account = account self.message = message def __str__(self): return '{} (account: {})'.format(self.message, self.account) class ContactsError(FriendsError): """Errors relating to EDS contact management.""" def __init__(self, message): self.message = message def __str__(self): return 'EDS: {}'.format(self.message) class UnsupportedProtocolError(FriendsError): def __init__(self, protocol): self.protocol = protocol def __str__(self): return 'Unsupported protocol: {}'.format(self.protocol) friends-0.2.0+14.04.20140217.1/friends/service/0000755000015201777760000000000012300444701020764 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/service/__init__.py0000644000015201777760000000000012300444435023067 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/service/templates/0000755000015201777760000000000012300444701022762 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/service/templates/__init__.py0000644000015201777760000000000012300444435025065 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/service/templates/com.canonical.Friends.Dispatcher.service.in0000644000015201777760000000013612300444435033137 0ustar pbusernogroup00000000000000[D-BUS Service] Name=com.canonical.Friends.Dispatcher Exec={BINDIR}/friends-dispatcher {ARGS} friends-0.2.0+14.04.20140217.1/friends/service/dispatcher.py0000644000015201777760000003052212300444435023472 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """DBus service object for general dispatching of commands.""" __all__ = [ 'Dispatcher', ] import json import logging import threading import dbus import dbus.service from gi.repository import GLib from contextlib import ContextDecorator from friends.utils.account import find_accounts from friends.utils.manager import protocol_manager from friends.utils.menus import MenuManager from friends.utils.model import Model, persist_model from friends.utils.shorteners import Short from friends.errors import ignored log = logging.getLogger(__name__) DBUS_INTERFACE = 'com.canonical.Friends.Dispatcher' STUB = lambda *ignore, **kwignore: None # Avoid race condition during shut-down _exit_lock = threading.Lock() class ManageTimers(ContextDecorator): """Exit the dispatcher 30s after the most recent method call returns.""" timers = set() callback = STUB timeout = 30 def __enter__(self): self.clear_all_timers() def __exit__(self, *ignore): self.set_new_timer() def clear_all_timers(self): while self.timers: timer_id = self.timers.pop() log.debug('Clearing timer id: {}'.format(timer_id)) GLib.source_remove(timer_id) def set_new_timer(self): # Concurrency will cause two methods to exit near each other, # causing two timers to be set, so we have to clear them again. self.clear_all_timers() log.debug('Starting new shutdown timer...') self.timers.add(GLib.timeout_add_seconds(self.timeout, self.terminate)) def terminate(self, *ignore): """Exit the dispatcher, but only if there are no active subthreads.""" with _exit_lock: if threading.activeCount() < 2: log.debug('No threads found, shutting down.') persist_model() self.timers.add(GLib.idle_add(self.callback)) else: log.debug('Delaying shutdown because active threads found.') self.set_new_timer() exit_after_idle = ManageTimers() class Dispatcher(dbus.service.Object): """This is the primary handler of dbus method calls.""" __dbus_object_path__ = '/com/canonical/friends/Dispatcher' def __init__(self, settings, mainloop): self.settings = settings self.bus = dbus.SessionBus() bus_name = dbus.service.BusName(DBUS_INTERFACE, bus=self.bus) super().__init__(bus_name, self.__dbus_object_path__) self.mainloop = mainloop self.accounts = find_accounts() self._unread_count = 0 self.menu_manager = MenuManager(self.Refresh, self.mainloop.quit) Model.connect('row-added', self._increment_unread_count) ManageTimers.callback = mainloop.quit def _increment_unread_count(self, model, itr): self._unread_count += 1 self.menu_manager.update_unread_count(self._unread_count) @exit_after_idle @dbus.service.method(DBUS_INTERFACE) def Refresh(self): """Download new messages from each connected protocol.""" self._unread_count = 0 log.debug('Refresh requested') # account.protocol() starts a new thread and then returns # immediately, so there is no delay or blocking during the # execution of this method. for account in self.accounts.values(): with ignored(NotImplementedError): account.protocol('receive') @exit_after_idle @dbus.service.method(DBUS_INTERFACE) def ClearIndicators(self): """Indicate that messages have been read. example: import dbus obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) service.ClearIndicators() """ self.menu_manager.update_unread_count(0) @exit_after_idle @dbus.service.method(DBUS_INTERFACE, in_signature='sss', out_signature='s', async_callbacks=('success','failure')) def Do(self, action, account_id='', arg='', success=STUB, failure=STUB): """Performs an arbitrary operation with an optional argument. This is how the client initiates retweeting, liking, searching, etc. See Dispatcher.Upload for an example of how to use the callbacks. example: import dbus obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) service.Do('like', '3', 'post_id') # Likes that FB post. service.Do('search', '', 'search terms') # Searches all accounts. service.Do('list', '6', 'list_id') # Fetch a single list. """ if account_id: accounts = [self.accounts.get(int(account_id))] if None in accounts: message = 'Could not find account: {}'.format(account_id) failure(message) log.error(message) return else: accounts = list(self.accounts.values()) called = False for account in accounts: log.debug('{}: {} {}'.format(account.id, action, arg)) args = (action, arg) if arg else (action,) # Not all accounts are expected to implement every action. with ignored(NotImplementedError): account.protocol(*args, success=success, failure=failure) called = True if not called: failure('No accounts supporting {} found.'.format(action)) @exit_after_idle @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s', async_callbacks=('success','failure')) def SendMessage(self, message, success=STUB, failure=STUB): """Posts a message/status update to all send_enabled accounts. It takes one argument, which is a message formated as a string. See Dispatcher.Upload for an example of how to use the callbacks. example: import dbus obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) service.SendMessage('Your message') """ sent = False for account in self.accounts.values(): if account.send_enabled: sent = True log.debug( 'Sending message to {}'.format( account.protocol._Name)) account.protocol( 'send', message, success=success, failure=failure, ) if not sent: failure('No send_enabled accounts found.') @exit_after_idle @dbus.service.method(DBUS_INTERFACE, in_signature='sss', out_signature='s', async_callbacks=('success','failure')) def SendReply(self, account_id, message_id, message, success=STUB, failure=STUB): """Posts a reply to the indicate message_id on account_id. It takes three arguments, all strings. See Dispatcher.Upload for an example of how to use the callbacks. example: import dbus obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) service.SendReply('6', '34245645347345626', 'Your reply') """ log.debug('Replying to {}, {}'.format(account_id, message_id)) account = self.accounts.get(int(account_id)) if account is not None: account.protocol( 'send_thread', message_id, message, success=success, failure=failure, ) else: message = 'Could not find account: {}'.format(account_id) failure(message) log.error(message) @exit_after_idle @dbus.service.method(DBUS_INTERFACE, in_signature='sss', out_signature='s', async_callbacks=('success','failure')) def Upload(self, account_id, uri, description, success=STUB, failure=STUB): """Upload an image to the specified account_id, asynchronously. It takes five arguments, three strings and two callback functions. The URI option is parsed by GFile and thus seamlessly supports uploading from http:// URLs as well as file:// paths. example: import dbus from dbus.mainloop.glib import DBusGMainLoop from gi.repository import GLib DBusGMainLoop(set_as_default=True) loop = GLib.MainLoop() obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) def success(destination_url): print('successfully uploaded to {}.'.format(destination_url)) def failure(message): print('failed to upload: {}'.format(message)) service.Upload( '6', 'file:///path/to/image.png', 'A beautiful picture.', reply_handler=success, error_handler=failure) loop.run() Note also that the callbacks are actually optional; you are free to ignore error conditions at your peril. """ log.debug('Uploading {} to {}'.format(uri, account_id)) account = self.accounts.get(int(account_id)) if account is not None: account.protocol( 'upload', uri, description, success=success, failure=failure, ) else: message = 'Could not find account: {}'.format(account_id) failure(message) log.error(message) @exit_after_idle @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s') def GetFeatures(self, protocol_name): """Returns a list of features supported by service as json string. example: import dbus, json obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) features = json.loads(service.GetFeatures('facebook')) """ protocol = protocol_manager.protocols.get(protocol_name) return json.dumps(protocol.get_features() if protocol else []) @exit_after_idle @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s') def URLShorten(self, message): """Shorten all the URLs in a message. Takes a message as a string, and returns the message with all it's URLs shortened. example: import dbus url = 'http://www.example.com/this/is/a/long/url' obj = dbus.SessionBus().get_object(DBUS_INTERFACE, '/com/canonical/friends/Dispatcher') service = dbus.Interface(obj, DBUS_INTERFACE) short_url = service.URLShorten(url) """ service_name = self.settings.get_string('urlshorter') log.info('Shortening with {}'.format(service_name)) if not self.settings.get_boolean('shorten-urls'): return message return Short(service_name).sub(message) friends-0.2.0+14.04.20140217.1/friends/service/mock_service.py0000644000015201777760000000705612300444435024023 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """DBus service object for general dispatching of commands.""" __all__ = [ 'Dispatcher', ] import json import logging import dbus import dbus.service from friends.service.dispatcher import DBUS_INTERFACE, STUB log = logging.getLogger(__name__) class Dispatcher(dbus.service.Object): """This object mocks the official friends-dispatcher dbus API.""" __dbus_object_path__ = '/com/canonical/friends/Dispatcher' def __init__(self, *ignore): self.bus = dbus.SessionBus() bus_name = dbus.service.BusName(DBUS_INTERFACE, bus=self.bus) super().__init__(bus_name, self.__dbus_object_path__) self._succeed = True @dbus.service.method(DBUS_INTERFACE) def Refresh(self): pass @dbus.service.method(DBUS_INTERFACE) def ClearIndicators(self): self._succeed = False @dbus.service.method(DBUS_INTERFACE, in_signature='sss', out_signature='s', async_callbacks=('success','failure')) def Do(self, action, account_id='', arg='', success=STUB, failure=STUB): message = "Called with: action={}, account_id={}, arg={}".format( action, account_id, arg) success(message) if self._succeed else failure(message) @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s', async_callbacks=('success','failure')) def SendMessage(self, message, success=STUB, failure=STUB): message = "Called with: message={}".format(message) success(message) if self._succeed else failure(message) @dbus.service.method(DBUS_INTERFACE, in_signature='sss', out_signature='s', async_callbacks=('success','failure')) def SendReply(self, account_id, message_id, msg, success=STUB, failure=STUB): message = "Called with: account_id={}, message_id={}, msg={}".format( account_id, message_id, msg) success(message) if self._succeed else failure(message) @dbus.service.method(DBUS_INTERFACE, in_signature='sss', out_signature='s', async_callbacks=('success','failure')) def Upload(self, account_id, uri, description, success=STUB, failure=STUB): message = "Called with: account_id={}, uri={}, description={}".format( account_id, uri, description) success(message) if self._succeed else failure(message) @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s') def GetFeatures(self, protocol_name): return json.dumps(protocol_name.split()) @dbus.service.method(DBUS_INTERFACE, in_signature='s', out_signature='s') def URLShorten(self, url): return str(len(url)) friends-0.2.0+14.04.20140217.1/friends/tests/0000755000015201777760000000000012300444701020466 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/tests/__init__.py0000644000015201777760000000000012300444435022571 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/tests/test_instagram.py0000644000015201777760000002164512300444435024100 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Instagram plugin.""" __all__ = [ 'TestInstagram', ] import os import tempfile import unittest import shutil from friends.protocols.instagram import Instagram from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock from friends.tests.mocks import TestModel, mock from friends.tests.mocks import EDSRegistry from friends.errors import FriendsError, AuthorizationError from friends.utils.cache import JsonCache @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) class TestInstagram(unittest.TestCase): """Test the Instagram API.""" def setUp(self): self._temp_cache = tempfile.mkdtemp() self._root = JsonCache._root = os.path.join( self._temp_cache, '{}.json') self.account = FakeAccount() self.protocol = Instagram(self.account) self.protocol.source_registry = EDSRegistry() def tearDown(self): TestModel.clear() shutil.rmtree(self._temp_cache) def test_features(self): # The set of public features. self.assertEqual(Instagram.get_features(), ['delete_contacts', 'home', 'like', 'receive', 'send_thread', 'unlike']) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='abc')) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'instagram-login.dat')) def test_successful_login(self, *mock): # Test that a successful response from instagram.com returning # the user's data, sets up the account dict correctly. self.protocol._login() self.assertEqual(self.account.access_token, 'abc') self.assertEqual(self.account.user_name, 'bpersons') self.assertEqual(self.account.user_id, '801') @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') def test_login_unsuccessful_authentication(self, *mock): # The user is not already logged in, but the act of logging in fails. self.assertRaises(AuthorizationError, self.protocol._login) self.assertIsNone(self.account.access_token) self.assertIsNone(self.account.user_name) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='abc')) @mock.patch('friends.protocols.instagram.Downloader.get_json', return_value=dict( error=dict(message='Bad access token', type='OAuthException', code=190))) def test_error_response(self, *mocks): with LogMock('friends.utils.base', 'friends.protocols.instagram') as log_mock: self.assertRaises( FriendsError, self.protocol.home, ) contents = log_mock.empty(trim=False) self.assertEqual(contents, 'Logging in to Instagram\n') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'instagram-full.dat')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.protocols.instagram.Instagram._login', return_value=True) def test_receive(self, *mocks): # Receive the feed for a user. self.maxDiff = None self.account.access_token = 'abc' self.assertEqual(self.protocol.receive(), 14) self.assertEqual(TestModel.get_n_rows(), 14) self.assertEqual(list(TestModel.get_row(0)), [ 'instagram', 88, '431474591469914097_223207800', 'messages', 'Josh', '223207800', 'joshwolp', False, '2013-04-11T04:50:01Z', 'joshwolp shared a picture on Instagram.', 'http://images.ak.instagram.com/profiles/profile_223207800_75sq_1347753109.jpg', 'http://instagram.com/joshwolp', 8, False, 'http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_5.jpg', '', 'http://instagram.com/p/X859raK8fx/', '', '', '', '', 0.0, 0.0, ]) self.assertEqual(list(TestModel.get_row(3)), [ 'instagram', 88, '431462132263145102', 'reply_to/431438012683111856_5891266', 'Syd', '5917696', 'squidneylol', False, '2013-04-11T04:25:15Z', 'I remember pushing that little guy of the swings a few times....', 'http://images.ak.instagram.com/profiles/profile_5917696_75sq_1336705905.jpg', '', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ]) @mock.patch('friends.protocols.instagram.Downloader') def test_send_thread(self, dload): dload().get_json.return_value = dict(id='comment_id') token = self.protocol._get_access_token = mock.Mock( return_value='abc') publish = self.protocol._publish_entry = mock.Mock( return_value='http://instagram.com/p/post_id') self.assertEqual( self.protocol.send_thread('post_id', 'Some witty response!'), 'http://instagram.com/p/post_id') token.assert_called_once_with() publish.assert_called_with(entry={'id': 'comment_id'}, stream='reply_to/post_id') self.assertEqual( dload.mock_calls, [mock.call(), mock.call( 'https://api.instagram.com/v1/media/post_id/comments?access_token=abc', method='POST', params=dict( access_token='abc', text='Some witty response!')), mock.call().get_json(), mock.call('https://api.instagram.com/v1/media/post_id/comments?access_token=abc', params=dict(access_token='abc')), mock.call().get_json(), ]) @mock.patch('friends.protocols.instagram.Downloader') def test_like(self, dload): dload().get_json.return_value = True token = self.protocol._get_access_token = mock.Mock( return_value='insta') inc_cell = self.protocol._inc_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.like('post_id'), 'post_id') inc_cell.assert_called_once_with('post_id', 'likes') set_cell.assert_called_once_with('post_id', 'liked', True) token.assert_called_once_with() dload.assert_called_with( 'https://api.instagram.com/v1/media/post_id/likes?access_token=insta', method='POST', params=dict(access_token='insta')) @mock.patch('friends.protocols.instagram.Downloader') def test_unlike(self, dload): dload.get_json.return_value = True token = self.protocol._get_access_token = mock.Mock( return_value='insta') dec_cell = self.protocol._dec_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.unlike('post_id'), 'post_id') dec_cell.assert_called_once_with('post_id', 'likes') set_cell.assert_called_once_with('post_id', 'liked', False) token.assert_called_once_with() dload.assert_called_once_with( 'https://api.instagram.com/v1/media/post_id/likes?access_token=insta', method='DELETE', params=dict(access_token='insta')) friends-0.2.0+14.04.20140217.1/friends/tests/test_foursquare.py0000644000015201777760000001036112300444435024300 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the FourSquare plugin.""" __all__ = [ 'TestFourSquare', ] import unittest from friends.protocols.foursquare import FourSquare from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock from friends.tests.mocks import TestModel, mock from friends.errors import AuthorizationError @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) class TestFourSquare(unittest.TestCase): """Test the FourSquare API.""" def setUp(self): self.account = FakeAccount() self.protocol = FourSquare(self.account) self.log_mock = LogMock('friends.utils.base', 'friends.protocols.foursquare') def tearDown(self): # Ensure that any log entries we haven't tested just get consumed so # as to isolate out test logger from other tests. self.log_mock.stop() # Reset the database. TestModel.clear() def test_features(self): # The set of public features. self.assertEqual(FourSquare.get_features(), ['delete_contacts', 'receive']) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') @mock.patch('friends.utils.http.Downloader.get_json', return_value=None) def test_unsuccessful_authentication(self, *mocks): self.assertRaises(AuthorizationError, self.protocol._login) self.assertIsNone(self.account.user_name) self.assertIsNone(self.account.user_id) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='tokeny goodness')) @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.protocols.foursquare.Downloader.get_json', return_value=dict( response=dict( user=dict(firstName='Bob', lastName='Loblaw', id='1234567')))) def test_successful_authentication(self, *mocks): self.assertTrue(self.protocol._login()) self.assertEqual(self.account.user_name, 'Bob Loblaw') self.assertEqual(self.account.user_id, '1234567') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'foursquare-full.dat')) @mock.patch('friends.protocols.foursquare.FourSquare._login', return_value=True) def test_receive(self, *mocks): self.account.access_token = 'tokeny goodness' self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual(self.protocol.receive(), 1) self.assertEqual(1, TestModel.get_n_rows()) expected = [ 'foursquare', 88, '50574c9ce4b0a9a6e84433a0', 'messages', 'Jimbob Smith', '', '', True, '2012-09-17T19:15:24Z', "Working on friends's foursquare plugin.", 'https://irs0.4sqi.net/img/user/100x100/5IEW3VIX55BBEXAO.jpg', '', 0, False, '', '', '', '', '', '', 'Pop Soda\'s Coffee House & Gallery', 49.88873164336725, -97.158043384552, ] self.assertEqual(list(TestModel.get_row(0)), expected) friends-0.2.0+14.04.20140217.1/friends/tests/test_account.py0000644000015201777760000001604712300444435023547 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Account class.""" __all__ = [ 'TestAccount', ] import unittest from friends.errors import UnsupportedProtocolError from friends.protocols.flickr import Flickr from friends.tests.mocks import FakeAccount, LogMock from friends.tests.mocks import TestModel, LogMock, mock from friends.utils.account import Account, _find_accounts_uoa class TestAccount(unittest.TestCase): """Test Account class.""" def setUp(self): self.log_mock = LogMock('friends.utils.account') def connect_side_effect(signal, callback, account): # The account service provides a .connect method that connects a # signal to a callback. We have to mock a side effect into the # connect() method to record this connection, which some of the # tests can then call. self._callback_signal = signal self._callback = callback self._callback_account = account # Set up the mock to return some useful values in the API expected by # the Account constructor. self.account_service = mock.Mock(**{ 'get_auth_data.return_value': mock.Mock(**{ 'get_credentials_id.return_value': 'fake credentials', 'get_method.return_value': 'fake method', 'get_mechanism.return_value': 'fake mechanism', 'get_parameters.return_value': { 'ConsumerKey': 'fake_key', 'ConsumerSecret': 'fake_secret'}, }), 'get_account.return_value': mock.Mock(**{ 'get_settings_dict.return_value': dict(send_enabled=True), 'id': 'fake_id', 'get_provider_name.return_value': 'flickr', }), 'get_service.return_value': mock.Mock(**{ 'get_name.return_value': 'fake_service', }), 'connect.side_effect': connect_side_effect, }) self.account = Account(self.account_service) def tearDown(self): self.log_mock.stop() def test_account_auth(self): # Test that the constructor initializes the 'auth' attribute. auth = self.account.auth self.assertEqual(auth.get_credentials_id(), 'fake credentials') self.assertEqual(auth.get_method(), 'fake method') self.assertEqual(auth.get_mechanism(), 'fake mechanism') self.assertEqual(auth.get_parameters(), dict(ConsumerKey='fake_key', ConsumerSecret='fake_secret')) def test_account_id(self): self.assertEqual(self.account.id, 'fake_id') def test_account_service(self): # The protocol attribute refers directly to the protocol used. self.assertIsInstance(self.account.protocol, Flickr) def test_account_unsupported(self): # Unsupported protocols raise exceptions in the Account constructor. mock = self.account_service.get_account() mock.get_provider_name.return_value = 'no service' with self.assertRaises(UnsupportedProtocolError) as cm: Account(self.account_service) self.assertEqual(cm.exception.protocol, 'no service') def test_on_account_changed(self): # Account.on_account_changed() gets called during the Account # constructor. Test that it has the expected original key value. self.assertEqual(self.account.send_enabled, True) def test_dict_filter(self): # The get_settings_dict() filters everything that doesn't start with # 'friends/' self._callback_account.get_settings_dict.assert_called_with('friends/') def test_on_account_changed_signal(self): # Test that when the account changes, and a 'changed' signal is # received, the callback is called and the account is updated. # # Start by simulating a change in the account service. other_dict = dict( send_enabled=False, bee='two', cat='three', ) adict = self.account_service.get_account().get_settings_dict adict.return_value = other_dict # Check that the signal has been connected. self.assertEqual(self._callback_signal, 'changed') # Check that the account is the object we expect it to be. self.assertEqual(self._callback_account, self.account_service.get_account()) # Simulate the signal. self._callback(self.account_service, self._callback_account) # Have the expected updates occurred? self.assertEqual(self.account.send_enabled, False) self.assertFalse(hasattr(self.account, 'bee')) self.assertFalse(hasattr(self.account, 'cat')) @mock.patch('friends.utils.account.Account._on_account_changed') @mock.patch('friends.utils.account.protocol_manager') def test_account_consumer_key(self, *mocks): account_service = mock.Mock() account_service.get_auth_data().get_parameters.return_value = ( dict(ConsumerKey='key', ConsumerSecret='secret')) acct = Account(account_service) self.assertEqual(acct.consumer_key, 'key') self.assertEqual(acct.consumer_secret, 'secret') @mock.patch('friends.utils.account.Account._on_account_changed') @mock.patch('friends.utils.account.protocol_manager') def test_account_client_id_sohu_style(self, *mocks): account_service = mock.Mock() account_service.get_auth_data().get_parameters.return_value = ( dict(ClientId='key', ClientSecret='secret')) acct = Account(account_service) self.assertEqual(acct.consumer_key, 'key') self.assertEqual(acct.consumer_secret, 'secret') @mock.patch('friends.utils.account.manager') @mock.patch('friends.utils.account.Account') @mock.patch('friends.utils.account.Accounts') def test_find_accounts(self, accts, acct, manager): service = mock.Mock() get_enabled = manager.get_enabled_account_services get_enabled.return_value = [service] manager.reset_mock() accounts = _find_accounts_uoa() get_enabled.assert_called_once_with() acct.assert_called_once_with(service) self.assertEqual(accounts, {acct().id: acct()}) self.assertEqual(self.log_mock.empty(), 'Flickr (fake_id) got send_enabled: True\n' 'Accounts found: 1\n') friends-0.2.0+14.04.20140217.1/friends/tests/test_notify.py0000644000015201777760000001366512300444435023426 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test our libnotify support.""" __all__ = [ 'TestNotifications', ] import unittest from friends.tests.mocks import FakeAccount, TestModel, mock from friends.utils.base import Base from friends.utils.notify import notify from datetime import datetime, timedelta RIGHT_NOW = datetime.now().isoformat() YESTERDAY = (datetime.now() - timedelta(1)).isoformat() LAST_WEEK = (datetime.now() - timedelta(7)).isoformat() class TestNotifications(unittest.TestCase): """Test notification details.""" def setUp(self): TestModel.clear() @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.notify.Avatar') @mock.patch('friends.utils.notify.GdkPixbuf') @mock.patch('friends.utils.notify.Notify') def test_publish_avatar_cache(self, notify, gdkpixbuf, avatar): Base._do_notify = lambda protocol, stream: True base = Base(FakeAccount()) base._publish( message='http://example.com!', message_id='1234', sender='Benjamin', timestamp=RIGHT_NOW, icon_uri='http://example.com/bob.jpg', ) avatar.get_image.assert_called_once_with('http://example.com/bob.jpg') gdkpixbuf.Pixbuf.new_from_file_at_size.assert_called_once_with( avatar.get_image(), 48, 48) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.base.notify') def test_publish_no_html(self, notify): Base._do_notify = lambda protocol, stream: True base = Base(FakeAccount()) base._publish( message='http://example.com!', message_id='1234', sender='Benjamin', timestamp=RIGHT_NOW, ) notify.assert_called_once_with('Benjamin', 'http://example.com!', '') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.base.notify') def test_publish_no_stale(self, notify): Base._do_notify = lambda protocol, stream: True base = Base(FakeAccount()) base._publish( message='http://example.com!', message_id='1234', sender='Benjamin', timestamp=LAST_WEEK, ) self.assertEqual(notify.call_count, 0) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.base.notify') def test_publish_all(self, notify): Base._do_notify = lambda protocol, stream: True base = Base(FakeAccount()) base._publish( message='notify!', message_id='1234', sender='Benjamin', timestamp=YESTERDAY, ) notify.assert_called_once_with('Benjamin', 'notify!', '') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.base.notify') def test_publish_mentions_private(self, notify): Base._do_notify = lambda protocol, stream: stream in ( 'mentions', 'private') base = Base(FakeAccount()) base._publish( message='This message is private!', message_id='1234', sender='Benjamin', stream='private', timestamp=RIGHT_NOW, ) notify.assert_called_once_with('Benjamin', 'This message is private!', '') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.base.notify') def test_publish_mention_fail(self, notify): Base._do_notify = lambda protocol, stream: stream in ( 'mentions', 'private') base = Base(FakeAccount()) base._publish( message='notify!', message_id='1234', sender='Benjamin', stream='messages', timestamp=RIGHT_NOW, ) self.assertEqual(notify.call_count, 0) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) @mock.patch('friends.utils.base.notify') def test_publish_mention_none(self, notify): Base._do_notify = lambda protocol, stream: False base = Base(FakeAccount()) base._publish( message='Ignore me!', message_id='1234', sender='Benjamin', stream='messages', timestamp=RIGHT_NOW, ) self.assertEqual(notify.call_count, 0) @mock.patch('friends.utils.notify.Notify') def test_dont_notify(self, Notify): notify('', '') notify('Bob Loblaw', '') notify('', 'hello, friend!') self.assertEqual(Notify.Notification.new.call_count, 0) @mock.patch('friends.utils.notify.Notify') def test_notify(self, Notify): notify('Bob Loblaw', 'hello, friend!', pixbuf='hi!') Notify.Notification.new.assert_called_once_with( 'Bob Loblaw', 'hello, friend!', 'friends') notification = Notify.Notification.new() notification.set_icon_from_pixbuf.assert_called_once_with('hi!') notification.show.assert_called_once_with() friends-0.2.0+14.04.20140217.1/friends/tests/test_avatars.py0000644000015201777760000001314112300444435023544 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Avatar cacher.""" __all__ = [ 'TestAvatars', ] import os import time import shutil import tempfile import unittest from datetime import date, timedelta from gi.repository import GdkPixbuf from pkg_resources import resource_filename from friends.tests.mocks import FakeSoupMessage, mock from friends.utils.avatar import Avatar @mock.patch('friends.utils.http._soup', mock.Mock()) class TestAvatars(unittest.TestCase): """Test Avatar logic.""" def setUp(self): # Create a temporary cache directory for storing the avatar image # files. This ensures that the user's operational environment can't # possibly interfere. self._temp_cache = tempfile.mkdtemp() self._avatar_cache = os.path.join( self._temp_cache, 'friends', 'avatars') def tearDown(self): # Clean up the temporary cache directory. shutil.rmtree(self._temp_cache) def test_noop(self): # If a tweet is missing a profile image, silently ignore it. self.assertEqual(Avatar.get_image(''), '') def test_hashing(self): # Check that the path hashing algorithm return a hash based on the # download url. with mock.patch('friends.utils.avatar.CACHE_DIR', self._avatar_cache): path = Avatar.get_path('fake_url') self.assertEqual( path.split(os.sep)[-3:], ['friends', 'avatars', # hashlib.sha1('fake_url'.encode('utf-8')).hexdigest() '4f37e5dc9d38391db1728048344c3ab5ff8cecb2']) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'ubuntu.png')) def test_cache_filled_on_miss(self): # When the cache is empty, downloading an avatar from a given url # fills the cache with the image data. with mock.patch('friends.utils.avatar.CACHE_DIR', self._avatar_cache) as cache_dir: # The file has not yet been downloaded because the directory does # not yet exist. It is created on demand. self.assertFalse(os.path.isdir(cache_dir)) os.makedirs(cache_dir) Avatar.get_image('http://example.com') # Soup.Message() was called once. Get the mock and check it. from friends.utils.http import Soup self.assertEqual(Soup.Message.call_count, 1) # Now the file is there. self.assertEqual( sorted(os.listdir(cache_dir)), # hashlib.sha1('http://example.com' # .encode('utf-8')).hexdigest() sorted(['89dce6a446a69d6b9bdc01ac75251e4c322bcdff', '89dce6a446a69d6b9bdc01ac75251e4c322bcdff.100px'])) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'ubuntu.png')) def test_cache_used_on_hit(self): # When the cache already contains the file, it is not downloaded. with mock.patch('friends.utils.avatar.CACHE_DIR', self._avatar_cache) as cache_dir: os.makedirs(cache_dir) src = resource_filename('friends.tests.data', 'ubuntu.png') dst = os.path.join( cache_dir, '89dce6a446a69d6b9bdc01ac75251e4c322bcdff') shutil.copyfile(src, dst) # Get the image, resulting in a cache hit. path = Avatar.get_image('http://example.com') # No download occurred. Check the mock. from friends.utils.http import Soup self.assertEqual(Soup.Message.call_count, 0) # Confirm that the resulting cache image is actually a PNG. with open(path, 'rb') as raw: # This is the PNG file format magic number, living in the first 8 # bytes of the file. self.assertEqual(raw.read(8), bytes.fromhex('89504E470D0A1A0A')) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'ubuntu.png')) def test_cache_file_contains_image(self): # The image is preserved in the cache file. with mock.patch('friends.utils.avatar.CACHE_DIR', self._avatar_cache) as cache_dir: os.makedirs(cache_dir) path = Avatar.get_image('http://example.com') # The image must have been downloaded at least once. pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) self.assertEqual(pixbuf.get_height(), 285) self.assertEqual(pixbuf.get_width(), 285) pixbuf = GdkPixbuf.Pixbuf.new_from_file(path + '.100px') self.assertEqual(pixbuf.get_height(), 100) self.assertEqual(pixbuf.get_width(), 100) # Confirm that the resulting cache image is actually a PNG. with open(path, 'rb') as raw: # This is the PNG file format magic number, living in the first 8 # bytes of the file. self.assertEqual(raw.read(8), bytes.fromhex('89504E470D0A1A0A')) friends-0.2.0+14.04.20140217.1/friends/tests/mocks.py0000644000015201777760000002657712300444435022201 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Mocks, doubles, and fakes for testing.""" __all__ = [ 'FakeAuth', 'FakeAccount', 'FakeSoupMessage', 'LogMock', 'mock', ] import os import hashlib import logging import threading import tempfile import shutil from io import StringIO from logging.handlers import QueueHandler from pkg_resources import resource_listdir, resource_string from queue import Empty, Queue from unittest import mock from urllib.parse import urlsplit from gi.repository import Dee # By default, Schema.FILES will look for the system-installed schema # file first, and then failing that will look for the one in the # source tree, for performance reasons. During testing though, we want # to look at the source tree first, so we reverse the list here. from friends.utils.model import Schema Schema.FILES = list(reversed(Schema.FILES)) SCHEMA = Schema() from friends.utils.base import Base from friends.utils.logging import LOG_FORMAT NEWLINE = '\n' # Create a test model that will not interfere with the user's environment. # We'll use this object as a mock of the real model. TestModel = Dee.SharedModel.new('com.canonical.Friends.TestSharedModel') TestModel.set_schema_full(SCHEMA.TYPES) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base.Base._get_access_token', mock.Mock(return_value='Access Tolkien')) @mock.patch('friends.utils.base.Base._get_oauth_headers', mock.Mock(return_value={})) def populate_fake_data(): """Dump a mixture of random data from our testsuite into TestModel. This is invoked by running 'friends-dispatcher --test' so that you can have some phony data in the model to test against. Just remember that the data appears in a separate model so as not to interfere with the user's official DeeModel stream. """ from friends.utils.cache import JsonCache from friends.protocols.facebook import Facebook from friends.protocols.flickr import Flickr from friends.protocols.twitter import Twitter from gi.repository import Dee temp_cache = tempfile.mkdtemp() root = JsonCache._root = os.path.join(temp_cache, '{}.json') protocols = { 'facebook-full.dat': Facebook(FakeAccount(account_id=1)), 'flickr-full.dat': Flickr(FakeAccount(account_id=2)), 'twitter-home.dat': Twitter(FakeAccount(account_id=3)), } for fake_name, protocol in protocols.items(): protocol.source_registry = EDSRegistry() with mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', fake_name)) as fake: protocol.receive() shutil.rmtree(temp_cache) class FakeAuth: get_credentials_id = lambda *ignore: 'fakeauth id' get_method = lambda *ignore: 'fakeauth method' get_mechanism = lambda *ignore: 'fakeauth mechanism' get_parameters = lambda *ignore: { 'ConsumerKey': 'fake', 'ConsumerSecret': 'alsofake', } class FakeAccount: """A fake account object for testing purposes.""" def __init__(self, service=None, account_id=88): self.consumer_secret = 'secret' self.consumer_key = 'consume' self.access_token = None self.secret_token = None self.user_full_name = None self.user_name = None self.user_id = None self.auth = FakeAuth() self.login_lock = threading.Lock() self.id = account_id self.protocol = Base(self) class FakeSoupMessage: """Mimic a Soup.Message that returns canned data.""" def __init__(self, path, resource, charset='utf-8', headers=None, response_code=200): # resource_string() always returns bytes. self._data = resource_string(path, resource) self.call_count = 0 self._charset = charset self._headers = {} if headers is None else headers self.status_code = response_code @property def response_body(self): return self @property def response_headers(self): return self @property def request_headers(self): return self def flatten(self): return self def get_data(self): return self._data def get_as_bytes(self): return self._data def get_content_type(self): return 'application/x-mock-data', dict(charset=self._charset) def get_uri(self): pieces = urlsplit(self.url) class FakeUri: host = pieces.netloc path = pieces.path return FakeUri() def get(self, header, default=None): return self._headers.get(header, default) def append(self, header, value): self._headers[header] = value def set_request(self, *args): pass def new(self, method, url): self.call_count += 1 self.method = method self.url = url return self class LogMock: """A mocker for capturing logging output in protocol classes. This ensures that the standard friends.service log file isn't polluted by the tests, and that the logging output in a sub-thread can be tested in the main thread. This class can be used either in a TestCase's setUp() and tearDown() methods, or as a context manager (i.e. in a `with` statement). When used as the latter, be sure to capture the contents of the log inside the with-clause since exiting the context manager will consume all left over log contents. Pass in the list of modules to mock, and it will mock all the 'log' attributes on those modules. The last component can be a '*' wildcard in which case it will mock all the modules found in that package. Instantiating this class automatically starts the mocking; call the .empty() method to gather the accumulated log messages, even from a sub-thread. In the .tearDown(), call .stop() to stop mocking. """ def __init__(self, *modules): self._queue = Queue() self._log = logging.getLogger('friends') handler = QueueHandler(self._queue) formatter = logging.Formatter(LOG_FORMAT, style='{') handler.setFormatter(formatter) self._log.addHandler(handler) # Capture effectively everything. This can't be NOTSET because by # definition, that propagates log messages to the root logger. self._log.setLevel(1) self._log.propagate = False # Create the mock, and then go through all the named modules, mocking # their 'log' attribute. self._patchers = [] for path in modules: prefix, dot, module = path.rpartition('.') if module == '*': # Partition again to get the parent package. subprefix, dot, parent = prefix.rpartition('.') for filename in resource_listdir(subprefix, parent): basename, extension = os.path.splitext(filename) if extension != '.py': continue patch_path = '{}.{}.__dict__'.format(prefix, basename) patcher = mock.patch.dict(patch_path, {'log': self._log}) self._patchers.append(patcher) else: patch_path = '{}.__dict__'.format(path) patcher = mock.patch.dict(patch_path, {'log': self._log}) self._patchers.append(patcher) # Start all the patchers. for patcher in self._patchers: patcher.start() def stop(self): # Empty the queue for test isolation. self.empty() for patcher in self._patchers: patcher.stop() # Get rid of the mock logger. del logging.Logger.manager.loggerDict['friends'] def empty(self, trim=True): """Return all the log messages written to this log. :param trim: Trim exception text to just the first and last line, with ellipses in between. You will usually want to do this since the exception details will contain file tracebacks with paths specific to your testing environment. :type trim: bool """ output = StringIO() while True: try: record = self._queue.get_nowait() except Empty: # The queue is exhausted. break # We have to print both the message, and explicitly the exc_text, # otherwise we won't see the exception traceback in the output. args = [record.getMessage()] if record.exc_text is None: # Nothing to include. pass elif trim: exc_lines = record.exc_text.splitlines() # Leave just the first and last lines, but put ellipses in # between. exc_lines[1:-1] = [' ...'] args.append(NEWLINE.join(exc_lines)) else: args.append(record.exc_text) print(*args, file=output) return output.getvalue() def __enter__(self): return self def __exit__(self, *exception_info): self.stop() return False class EDSBookClientMock: """A Mocker object to simulate use of BookClient.""" def __init__(self): pass def open_sync(val1, val2, val3): pass def add_contact_sync(val1, contact, cancellable): return True def get_contacts_sync(val1, val2, val3): return [True, [{'name':'john doe', 'id': 11111}]] def remove_contact_sync(val1, val2): pass class EDSExtension: """A Extension mocker object for testing create source.""" def __init__(self): pass def set_backend_name(self, name): pass class EDSSource: """Simulate a Source object to create address books in EDS.""" def __init__(self, val1, val2): pass def set_display_name(self, name): self.name = name def get_display_name(self): return self.name def set_parent(self, parent): pass def get_uid(self): return self.name def get_extension(self, extension_name): return EDSExtension() class EDSRegistry: """A Mocker object for the registry.""" def __init__(self): pass def commit_source_sync(self, source, val1): return True def list_sources(self, category): res = [] s1 = EDSSource(None, None) s1.set_display_name('test-facebook-contacts') res.append(s1) s2 = EDSSource(None, None) s2.set_display_name('test-twitter-contacts') res.append(s2) return res def ref_source(self, src_uid): s1 = EDSSource(None, None) s1.set_display_name('friends-testsuite-contacts') return s1 friends-0.2.0+14.04.20140217.1/friends/tests/test_identica.py0000644000015201777760000002467712300444435023703 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Identica plugin.""" __all__ = [ 'TestIdentica', ] import os import tempfile import unittest import shutil from friends.protocols.identica import Identica from friends.tests.mocks import FakeAccount, LogMock, TestModel, mock from friends.utils.cache import JsonCache from friends.errors import AuthorizationError @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) @mock.patch('friends.utils.base.Model', TestModel) class TestIdentica(unittest.TestCase): """Test the Identica API.""" def setUp(self): self._temp_cache = tempfile.mkdtemp() self._root = JsonCache._root = os.path.join( self._temp_cache, '{}.json') self.account = FakeAccount() self.protocol = Identica(self.account) self.log_mock = LogMock('friends.utils.base', 'friends.protocols.twitter') def tearDown(self): # Ensure that any log entries we haven't tested just get consumed so # as to isolate out test logger from other tests. self.log_mock.stop() shutil.rmtree(self._temp_cache) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') @mock.patch('friends.utils.http.Downloader.get_json', return_value=None) def test_unsuccessful_authentication(self, *mocks): self.assertRaises(AuthorizationError, self.protocol._login) self.assertIsNone(self.account.user_name) self.assertIsNone(self.account.user_id) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='some clever fake data', TokenSecret='sssssshhh!')) def test_successful_authentication(self, *mocks): get_url = self.protocol._get_url = mock.Mock( return_value=dict(id='1234', screen_name='therealrobru')) self.assertTrue(self.protocol._login()) self.assertEqual(self.account.user_name, 'therealrobru') self.assertEqual(self.account.user_id, '1234') self.assertEqual(self.account.access_token, 'some clever fake data') self.assertEqual(self.account.secret_token, 'sssssshhh!') get_url.assert_called_once_with('http://identi.ca/api/users/show.json') def test_mentions(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.protocol.mentions() publish.assert_called_with('tweet', stream='mentions') get_url.assert_called_with( 'http://identi.ca/api/statuses/mentions.json?count=50') def test_user(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.protocol.user() publish.assert_called_with('tweet', stream='messages') get_url.assert_called_with( 'http://identi.ca/api/statuses/user_timeline.json?screen_name=') def test_list(self): self.protocol._get_url = mock.Mock(return_value=['tweet']) self.protocol._publish_tweet = mock.Mock() self.assertRaises(NotImplementedError, self.protocol.list, 'some_list_id') def test_lists(self): self.protocol._get_url = mock.Mock( return_value=[dict(id_str='twitlist')]) self.protocol.list = mock.Mock() self.assertRaises(NotImplementedError, self.protocol.lists) def test_private(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.protocol.private() publish.assert_called_with('tweet', stream='private') self.assertEqual( get_url.mock_calls, [mock.call('http://identi.ca/api/direct_messages.json?count=50'), mock.call('http://identi.ca/api/direct_messages' + '/sent.json?count=50')]) def test_send_private(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._publish_tweet = mock.Mock() self.protocol.send_private('pumpichank', 'Are you mocking me?') publish.assert_called_with('tweet', stream='private') get_url.assert_called_with( 'http://identi.ca/api/direct_messages/new.json', dict(text='Are you mocking me?', screen_name='pumpichank')) def test_send(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._publish_tweet = mock.Mock() self.protocol.send('Hello, twitterverse!') publish.assert_called_with('tweet') get_url.assert_called_with( 'http://identi.ca/api/statuses/update.json', dict(status='Hello, twitterverse!')) def test_send_thread(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._publish_tweet = mock.Mock() self.protocol.send_thread( '1234', 'Why yes, I would love to respond to your tweet @pumpichank!') publish.assert_called_with('tweet', stream='reply_to/1234') get_url.assert_called_with( 'http://identi.ca/api/statuses/update.json', dict(status= 'Why yes, I would love to respond to your tweet @pumpichank!', in_reply_to_status_id='1234')) def test_delete(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._unpublish = mock.Mock() self.protocol.delete('1234') publish.assert_called_with('1234') get_url.assert_called_with( 'http://identi.ca/api/statuses/destroy/1234.json', dict(trim_user='true')) def test_retweet(self): tweet=dict(tweet='twit') get_url = self.protocol._get_url = mock.Mock(return_value=tweet) publish = self.protocol._publish_tweet = mock.Mock() self.protocol.retweet('1234') publish.assert_called_with(tweet) get_url.assert_called_with( 'http://identi.ca/api/statuses/retweet/1234.json', dict(trim_user='false')) def test_unfollow(self): get_url = self.protocol._get_url = mock.Mock() self.protocol.unfollow('pumpichank') get_url.assert_called_with( 'http://identi.ca/api/friendships/destroy.json', dict(screen_name='pumpichank')) def test_follow(self): get_url = self.protocol._get_url = mock.Mock() self.protocol.follow('pumpichank') get_url.assert_called_with( 'http://identi.ca/api/friendships/create.json', dict(screen_name='pumpichank', follow='true')) def test_tag(self): self.protocol._get_url = mock.Mock( return_value=dict(statuses=['tweet'])) self.protocol._publish_tweet = mock.Mock() self.assertRaises(NotImplementedError, self.protocol.tag, 'hashtag') def test_search(self): get_url = self.protocol._get_url = mock.Mock( return_value=dict(results=['tweet'])) publish = self.protocol._publish_tweet = mock.Mock() self.protocol.search('hello') publish.assert_called_with('tweet', stream='search/hello') get_url.assert_called_with( 'http://identi.ca/api/search.json?q=hello') def test_like(self): get_url = self.protocol._get_url = mock.Mock() inc_cell = self.protocol._inc_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.like('1234'), '1234') inc_cell.assert_called_once_with('1234', 'likes') set_cell.assert_called_once_with('1234', 'liked', True) get_url.assert_called_with( 'http://identi.ca/api/favorites/create/1234.json', dict(id='1234')) def test_unlike(self): get_url = self.protocol._get_url = mock.Mock() dec_cell = self.protocol._dec_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.unlike('1234'), '1234') dec_cell.assert_called_once_with('1234', 'likes') set_cell.assert_called_once_with('1234', 'liked', False) get_url.assert_called_with( 'http://identi.ca/api/favorites/destroy/1234.json', dict(id='1234')) def test_contacts(self): get = self.protocol._get_url = mock.Mock( return_value=dict(ids=[1,2],name='Bob',screen_name='bobby')) prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False) push = self.protocol._push_to_eds = mock.Mock() self.assertEqual(self.protocol.contacts(), 2) self.assertEqual( get.call_args_list, [mock.call('http://identi.ca/api/friends/ids.json'), mock.call(url='http://identi.ca/api/users/show.json?user_id=1'), mock.call(url='http://identi.ca/api/users/show.json?user_id=2')]) self.assertEqual( prev.call_args_list, [mock.call('1'), mock.call('2')]) self.assertEqual( push.call_args_list, [mock.call(link='https://identi.ca/bobby', nick='bobby', uid='1', name='Bob'), mock.call(link='https://identi.ca/bobby', nick='bobby', uid='2', name='Bob')]) friends-0.2.0+14.04.20140217.1/friends/tests/test_mock_dispatcher.py0000644000015201777760000000414312300444435025244 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the dispatcher directly, without dbus.""" __all__ = [ 'TestMockDispatcher', ] import dbus.service import unittest from dbus.mainloop.glib import DBusGMainLoop from friends.service.mock_service import Dispatcher as MockDispatcher from friends.service.dispatcher import Dispatcher # Set up the DBus main loop. DBusGMainLoop(set_as_default=True) def get_signature(klass, signature): """Extract dbus in/out signatures from a dbus class.""" return [ (m, getattr(getattr(klass, m), signature)) for m in dir(klass) if hasattr(getattr(klass, m), signature) ] class TestMockDispatcher(unittest.TestCase): """Ensure our mock Dispatcher has the same API as the real one.""" def test_api_similarity(self): real = [m for m in dir(Dispatcher) if hasattr(getattr(Dispatcher, m), '_dbus_interface')] mock = [m for m in dir(MockDispatcher) if hasattr(getattr(MockDispatcher, m), '_dbus_interface')] self.assertEqual(real, mock) def test_in_signatures(self): real = get_signature(Dispatcher, '_dbus_in_signature') mock = get_signature(MockDispatcher, '_dbus_in_signature') self.assertEqual(real, mock) def test_out_signatures(self): real = get_signature(Dispatcher, '_dbus_out_signature') mock = get_signature(MockDispatcher, '_dbus_out_signature') self.assertEqual(real, mock) friends-0.2.0+14.04.20140217.1/friends/tests/test_authentication.py0000644000015201777760000001165512300444435025132 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the authentication service. We do a lot of mocking so that we don't have to talk to the actual backend authentication service. """ __all__ = [ 'TestAuthentication', ] import unittest from friends.utils.authentication import Authentication from friends.tests.mocks import FakeAccount, LogMock, mock from friends.errors import AuthorizationError class FakeAuthSession: results = None @classmethod def new(cls, id, method): return cls() def process(self, parameters, mechanism, callback, ignore): # Pass in fake data. The callback expects a session, reply, # error, and user_data arguments. We'll use the parameters # argument as a way to specify whether an error occurred during # authentication or not. callback( None, self.results, parameters if hasattr(parameters, 'message') else None, None) class FakeSignon: class AuthSession(FakeAuthSession): results = dict(AccessToken='auth reply') class FailingSignon: class AuthSession(FakeAuthSession): results = dict(NoAccessToken='fail') class TestAuthentication(unittest.TestCase): """Test authentication.""" def setUp(self): self.log_mock = LogMock('friends.utils.authentication') self.account = FakeAccount() self.account.auth.get_credentials_id = lambda *ignore: 'my id' self.account.auth.get_method = lambda *ignore: 'some method' self.account.auth.get_parameters = lambda *ignore: 'change me' self.account.auth.get_mechanism = lambda *ignore: 'whatever' def tearDown(self): self.log_mock.stop() @mock.patch('friends.utils.authentication.Signon', FakeSignon) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') def test_successful_login(self, accounts, manager): manager.get_account().list_services.return_value = ['foo'] # Prevent an error in the callback. accounts.AccountService.new().get_auth_data( ).get_parameters.return_value = False authenticator = Authentication(self.account.id) reply = authenticator.login() self.assertEqual(reply, dict(AccessToken='auth reply')) self.assertEqual(self.log_mock.empty(), '_login_cb completed\n') @mock.patch('friends.utils.authentication.Signon', FailingSignon) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') def test_missing_access_token(self, accounts, manager): manager.get_account().list_services.return_value = ['foo'] # Prevent an error in the callback. self.account.auth.get_parameters = lambda *ignore: False authenticator = Authentication(self.account.id) self.assertRaises(AuthorizationError, authenticator.login) @mock.patch('friends.utils.authentication.Signon', FakeSignon) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') def test_failed_login(self, accounts, manager): # Trigger an error in the callback. class Error: message = 'who are you?' manager.get_account().list_services.return_value = ['foo'] accounts.AccountService.new( ).get_auth_data().get_parameters.return_value = Error authenticator = Authentication(self.account.id) self.assertRaises(AuthorizationError, authenticator.login) @mock.patch('friends.utils.authentication.Signon', FakeSignon) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') def test_exception_correct_thread(self, accounts, manager): manager.get_account().list_services.return_value = ['foo'] authenticator = Authentication(self.account) # If this were to raise any exception for any reason, this # test will fail. This method can't be allowed to raise # exceptions because it doesn't run in the safety of a # subthread where those are caught and logged nicely. authenticator._login_cb('session', 'reply', 'error', 'data') self.assertEqual(authenticator._reply, 'reply') self.assertEqual(authenticator._error, 'error') friends-0.2.0+14.04.20140217.1/friends/tests/test_flickr.py0000644000015201777760000002552012300444435023361 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Flickr plugin.""" __all__ = [ 'TestFlickr', ] import unittest from friends.errors import AuthorizationError, FriendsError from friends.protocols.flickr import Flickr from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock from friends.tests.mocks import TestModel, mock @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) class TestFlickr(unittest.TestCase): """Test the Flickr API.""" def setUp(self): self.maxDiff = None self.account = FakeAccount() self.protocol = Flickr(self.account) self.protocol._get_oauth_headers = lambda *ignore, **kwignore: {} self.log_mock = LogMock('friends.utils.base', 'friends.protocols.flickr') TestModel.clear() def tearDown(self): self.log_mock.stop() # Reset the database. TestModel.clear() def test_features(self): # The set of public features. self.assertEqual(Flickr.get_features(), ['delete_contacts', 'receive', 'upload']) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat')) @mock.patch('friends.utils.base.Model', TestModel) def test_already_logged_in(self): # Try to get the data when already logged in. self.account.access_token = 'original token' # There's no data, and no way to test that the user_nsid was actually # used, except for the side effect of not getting an # AuthorizationError. self.protocol.receive() # No error messages. self.assertEqual(self.log_mock.empty(), '') # But also no photos. self.assertEqual(TestModel.get_n_rows(), 0) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat')) @mock.patch('friends.utils.base.Model', TestModel) def test_successful_login(self): # The user is not already logged in, but the act of logging in # succeeds. def side_effect(): # Perform a successful login. self.account.user_id = 'cate' return True with mock.patch.object(self.protocol, '_login', side_effect=side_effect): self.protocol.receive() # No error message. self.assertEqual(self.log_mock.empty(), '') # But also no photos. self.assertEqual(TestModel.get_n_rows(), 0) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat')) def test_login_unsuccessful_authentication_no_callback(self, *mocks): # Logging in required communication with the account service to get an # AccessToken, but this fails. self.assertRaises(AuthorizationError, self.protocol.receive) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat')) @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(username='Bob Dobbs', user_nsid='bob', AccessToken='123', TokenSecret='abc')) def test_login_successful_authentication(self, *mocks): # Logging in required communication with the account service to get an # AccessToken, but this fails. self.protocol.receive() # Make sure our account data got properly updated. self.assertEqual(self.account.user_name, 'Bob Dobbs') self.assertEqual(self.account.user_id, 'bob') self.assertEqual(self.account.access_token, '123') self.assertEqual(self.account.secret_token, 'abc') @mock.patch('friends.utils.base.Model', TestModel) def test_get(self): # Make sure that the REST GET url looks right. token = self.protocol._get_access_token = mock.Mock() class fake: def get_json(*ignore): return {} with mock.patch('friends.protocols.flickr.Downloader') as cm: cm.return_value = fake() self.assertEqual(self.protocol.receive(), 0) token.assert_called_once_with() # GET was called once. cm.assert_called_once_with( 'http://api.flickr.com/services/rest', method='GET', params=dict( extras='date_upload,owner_name,icon_server,geo', format='json', nojsoncallback='1', api_key='consume', method='flickr.photos.getContactsPhotos', ), headers={}) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'flickr-nophotos.dat')) @mock.patch('friends.utils.base.Model', TestModel) def test_no_photos(self): # The JSON data in response to the GET request returned no photos. with mock.patch.object( self.protocol, '_get_access_token', return_value='token'): # No photos are returned in the JSON data. self.assertEqual(self.protocol.receive(), 0) self.assertEqual(TestModel.get_n_rows(), 0) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'flickr-full.dat')) @mock.patch('friends.utils.base.Model', TestModel) def test_flickr_data(self): # Start by setting up a fake account id. self.account.id = 69 with mock.patch.object(self.protocol, '_get_access_token', return_value='token'): self.assertEqual(self.protocol.receive(), 10) self.assertEqual(TestModel.get_n_rows(), 10) self.assertEqual( list(TestModel.get_row(0)), ['flickr', 69, '8552892154', 'images', 'raise my voice', '47303164@N00', 'raise my voice', True, '2013-03-12T19:51:42Z', 'Chocolate chai #yegcoffee', 'http://farm1.static.flickr.com/93/buddyicons/47303164@N00.jpg', 'http://www.flickr.com/photos/47303164@N00/8552892154', 0, False, 'http://farm9.static.flickr.com/8378/8552892154_a_m.jpg', '', 'http://www.flickr.com/photos/47303164@N00/8552892154', '', '', 'http://farm9.static.flickr.com/8378/8552892154_a_t.jpg', '', 0.0, 0.0, ]) self.assertEqual( list(TestModel.get_row(4)), ['flickr', 69, '8550829193', 'images', 'Nelson Webb', '27204141@N05', 'Nelson Webb', True, '2013-03-12T13:54:10Z', 'St. Michael - The Archangel', 'http://farm3.static.flickr.com/2047/buddyicons/27204141@N05.jpg', 'http://www.flickr.com/photos/27204141@N05/8550829193', 0, False, 'http://farm9.static.flickr.com/8246/8550829193_e_m.jpg', '', 'http://www.flickr.com/photos/27204141@N05/8550829193', '', '', 'http://farm9.static.flickr.com/8246/8550829193_e_t.jpg', '', 53.833156, -112.330784, ]) @mock.patch('friends.utils.http.Soup.form_request_new_from_multipart', lambda *ignore: FakeSoupMessage('friends.tests.data', 'flickr-xml.dat')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Gio.File') @mock.patch('friends.protocols.flickr.time.time', lambda: 1361292793) def test_upload(self, gfile): self.account.user_name = 'freddyjimbobjones' gfile.new_for_uri().load_contents.return_value = [True, 'data'.encode()] token = self.protocol._get_access_token = mock.Mock() publish = self.protocol._publish = mock.Mock() avatar = self.protocol._get_avatar = mock.Mock() avatar.return_value = '/path/to/cached/avatar' self.assertEqual( self.protocol.upload( 'file:///path/to/some.jpg', 'Beautiful photograph!'), 'http://www.flickr.com/photos/freddyjimbobjones/8488552823') token.assert_called_with() publish.assert_called_with( message='Beautiful photograph!', timestamp='2013-02-19T16:53:13Z', stream='images', message_id='8488552823', from_me=True, sender=None, sender_nick='freddyjimbobjones', icon_uri='/path/to/cached/avatar', url='http://www.flickr.com/photos/freddyjimbobjones/8488552823', sender_id=None) @mock.patch('friends.utils.http.Soup.form_request_new_from_multipart', lambda *ignore: FakeSoupMessage('friends.tests.data', 'flickr-xml-error.dat')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Gio.File') def test_failing_upload(self, gfile): gfile.new_for_uri().load_contents.return_value = [True, 'data'.encode()] token = self.protocol._get_access_token = mock.Mock() publish = self.protocol._publish = mock.Mock() self.assertRaises( FriendsError, self.protocol.upload, 'file:///path/to/some.jpg', 'Beautiful photograph!') token.assert_called_with() self.assertEqual(publish.call_count, 0) friends-0.2.0+14.04.20140217.1/friends/tests/test_dispatcher.py0000644000015201777760000002611512300444435024236 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the dispatcher directly, without dbus.""" __all__ = [ 'TestDispatcher', ] import dbus.service import unittest import json from dbus.mainloop.glib import DBusGMainLoop from friends.service.dispatcher import Dispatcher, ManageTimers, STUB from friends.tests.mocks import LogMock, mock # Set up the DBus main loop. DBusGMainLoop(set_as_default=True) @mock.patch('friends.service.dispatcher.GLib.timeout_add_seconds', mock.Mock(return_value=42)) @mock.patch('friends.service.dispatcher.GLib.source_remove', mock.Mock(return_value=True)) class TestDispatcher(unittest.TestCase): """Test the dispatcher's ability to dispatch.""" @mock.patch('dbus.service.BusName') @mock.patch('friends.service.dispatcher.find_accounts') @mock.patch('dbus.service.Object.__init__') def setUp(self, *mocks): self.log_mock = LogMock('friends.service.dispatcher', 'friends.utils.account') self.dispatcher = Dispatcher(mock.Mock(), mock.Mock()) self.dispatcher.accounts = {} def tearDown(self): self.log_mock.stop() @mock.patch('friends.service.dispatcher.threading') def test_refresh(self, threading_mock): account = mock.Mock() threading_mock.activeCount.return_value = 1 self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.values.return_value = [account] self.assertIsNone(self.dispatcher.Refresh()) self.dispatcher.accounts.values.assert_called_once_with() account.protocol.assert_called_once_with('receive') self.assertEqual(self.log_mock.empty(), 'Clearing timer id: 42\n' 'Refresh requested\n' 'Starting new shutdown timer...\n') def test_clear_indicators(self): self.dispatcher.menu_manager = mock.Mock() self.dispatcher.ClearIndicators() self.dispatcher.menu_manager.update_unread_count.assert_called_once_with(0) def test_do(self): account = mock.Mock() account.id = '345' self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.get.return_value = account self.dispatcher.Do('like', '345', '23346356767354626') self.dispatcher.accounts.get.assert_called_once_with(345) account.protocol.assert_called_once_with( 'like', '23346356767354626', success=STUB, failure=STUB) self.assertEqual(self.log_mock.empty(), 'Clearing timer id: 42\n' '345: like 23346356767354626\n' 'Starting new shutdown timer...\n') def test_failing_do(self): account = mock.Mock() self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.get.return_value = None self.dispatcher.Do('unlike', '6', '23346356767354626') self.dispatcher.accounts.get.assert_called_once_with(6) self.assertEqual(account.protocol.call_count, 0) self.assertEqual(self.log_mock.empty(), 'Clearing timer id: 42\n' 'Could not find account: 6\n' 'Starting new shutdown timer...\n') def test_send_message(self): account1 = mock.Mock() account2 = mock.Mock() account3 = mock.Mock() account2.send_enabled = False self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.values.return_value = [ account1, account2, account3, ] self.dispatcher.SendMessage('Howdy friends!') self.dispatcher.accounts.values.assert_called_once_with() account1.protocol.assert_called_once_with( 'send', 'Howdy friends!', success=STUB, failure=STUB) account3.protocol.assert_called_once_with( 'send', 'Howdy friends!', success=STUB, failure=STUB) self.assertEqual(account2.protocol.call_count, 0) def test_send_reply(self): account = mock.Mock() self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.get.return_value = account self.dispatcher.SendReply('2', 'objid', '[Hilarious Response]') self.dispatcher.accounts.get.assert_called_once_with(2) account.protocol.assert_called_once_with( 'send_thread', 'objid', '[Hilarious Response]', success=STUB, failure=STUB) self.assertEqual(self.log_mock.empty(), 'Clearing timer id: 42\n' 'Replying to 2, objid\n' 'Starting new shutdown timer...\n') def test_send_reply_failed(self): account = mock.Mock() self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.get.return_value = None self.dispatcher.SendReply('2', 'objid', '[Hilarious Response]') self.dispatcher.accounts.get.assert_called_once_with(2) self.assertEqual(account.protocol.call_count, 0) self.assertEqual(self.log_mock.empty(), 'Clearing timer id: 42\n' 'Replying to 2, objid\n' 'Could not find account: 2\n' 'Starting new shutdown timer...\n') def test_upload_async(self): account = mock.Mock() self.dispatcher.accounts = mock.Mock() self.dispatcher.accounts.get.return_value = account success = mock.Mock() failure = mock.Mock() self.dispatcher.Upload('2', 'file://path/to/image.png', 'A thousand words', success=success, failure=failure) self.dispatcher.accounts.get.assert_called_once_with(2) account.protocol.assert_called_once_with( 'upload', 'file://path/to/image.png', 'A thousand words', success=success, failure=failure, ) self.assertEqual(self.log_mock.empty(), 'Clearing timer id: 42\n' 'Uploading file://path/to/image.png to 2\n' 'Starting new shutdown timer...\n') def test_get_features(self): self.assertEqual(json.loads(self.dispatcher.GetFeatures('facebook')), ['contacts', 'delete', 'delete_contacts', 'home', 'like', 'receive', 'search', 'send', 'send_thread', 'unlike', 'upload', 'wall']) self.assertEqual(json.loads(self.dispatcher.GetFeatures('twitter')), ['contacts', 'delete', 'delete_contacts', 'follow', 'home', 'like', 'list', 'lists', 'mentions', 'private', 'receive', 'retweet', 'search', 'send', 'send_private', 'send_thread', 'tag', 'unfollow', 'unlike', 'user']) self.assertEqual(json.loads(self.dispatcher.GetFeatures('identica')), ['contacts', 'delete', 'delete_contacts', 'follow', 'home', 'like', 'mentions', 'private', 'receive', 'retweet', 'search', 'send', 'send_private', 'send_thread', 'unfollow', 'unlike', 'user']) self.assertEqual(json.loads(self.dispatcher.GetFeatures('flickr')), ['delete_contacts', 'receive', 'upload']) self.assertEqual(json.loads(self.dispatcher.GetFeatures('foursquare')), ['delete_contacts', 'receive']) @mock.patch('friends.service.dispatcher.logging') def test_urlshorten_already_shortened(self, logging_mock): self.assertEqual( 'http://tinyurl.com/foo', self.dispatcher.URLShorten('http://tinyurl.com/foo')) @mock.patch('friends.service.dispatcher.logging') @mock.patch('friends.service.dispatcher.Short') def test_urlshorten(self, short_mock, logging_mock): short_mock().sub.return_value = 'short url' short_mock.reset_mock() self.dispatcher.settings.get_string.return_value = 'is.gd' long_url = 'http://example.com/really/really/long' self.assertEqual( self.dispatcher.URLShorten(long_url), 'short url') self.dispatcher.settings.get_boolean.assert_called_once_with( 'shorten-urls') short_mock.assert_called_once_with('is.gd') short_mock.return_value.sub.assert_called_once_with( long_url) @mock.patch('friends.service.dispatcher.GLib') def test_manage_timers_clear(self, glib): glib.source_remove.reset_mock() manager = ManageTimers() manager.timers = {1} manager.__enter__() glib.source_remove.assert_called_once_with(1) manager.timers = {1, 2, 3} manager.clear_all_timers() self.assertEqual(glib.source_remove.call_count, 4) @mock.patch('friends.service.dispatcher.GLib') def test_manage_timers_set(self, glib): glib.timeout_add_seconds.reset_mock() manager = ManageTimers() manager.timers = set() manager.clear_all_timers = mock.Mock() manager.__exit__() glib.timeout_add_seconds.assert_called_once_with(30, manager.terminate) manager.clear_all_timers.assert_called_once_with() self.assertEqual(len(manager.timers), 1) @mock.patch('friends.service.dispatcher.persist_model') @mock.patch('friends.service.dispatcher.threading') @mock.patch('friends.service.dispatcher.GLib') def test_manage_timers_terminate(self, glib, thread, persist): manager = ManageTimers() manager.timers = set() thread.activeCount.return_value = 1 manager.terminate() thread.activeCount.assert_called_once_with() persist.assert_called_once_with() glib.idle_add.assert_called_once_with(manager.callback) @mock.patch('friends.service.dispatcher.persist_model') @mock.patch('friends.service.dispatcher.threading') @mock.patch('friends.service.dispatcher.GLib') def test_manage_timers_dont_kill_threads(self, glib, thread, persist): manager = ManageTimers() manager.timers = set() manager.set_new_timer = mock.Mock() thread.activeCount.return_value = 10 manager.terminate() thread.activeCount.assert_called_once_with() manager.set_new_timer.assert_called_once_with() friends-0.2.0+14.04.20140217.1/friends/tests/test_facebook.py0000644000015201777760000006634512300444435023672 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Facebook plugin.""" __all__ = [ 'TestFacebook', ] import os import tempfile import unittest import shutil from gi.repository import GLib, EDataServer, EBookContacts from pkg_resources import resource_filename from friends.protocols.facebook import Facebook from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock from friends.tests.mocks import TestModel, mock from friends.tests.mocks import EDSBookClientMock, EDSRegistry from friends.errors import ContactsError, FriendsError, AuthorizationError from friends.utils.cache import JsonCache @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) class TestFacebook(unittest.TestCase): """Test the Facebook API.""" def setUp(self): self._temp_cache = tempfile.mkdtemp() self._root = JsonCache._root = os.path.join( self._temp_cache, '{}.json') self.account = FakeAccount() self.protocol = Facebook(self.account) self.protocol.source_registry = EDSRegistry() def tearDown(self): TestModel.clear() shutil.rmtree(self._temp_cache) def test_features(self): # The set of public features. self.assertEqual(Facebook.get_features(), ['contacts', 'delete', 'delete_contacts', 'home', 'like', 'receive', 'search', 'send', 'send_thread', 'unlike', 'upload', 'wall']) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='abc')) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'facebook-login.dat')) def test_successful_login(self, *mocks): # Test that a successful response from graph.facebook.com returning # the user's data, sets up the account dict correctly. self.protocol._login() self.assertEqual(self.account.access_token, 'abc') self.assertEqual(self.account.user_name, 'Bart Person') self.assertEqual(self.account.user_id, '801') @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') def test_login_unsuccessful_authentication(self, *mocks): # The user is not already logged in, but the act of logging in fails. self.assertRaises(AuthorizationError, self.protocol._login) self.assertIsNone(self.account.access_token) self.assertIsNone(self.account.user_name) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='abc')) @mock.patch('friends.protocols.facebook.Downloader.get_json', return_value=dict( error=dict(message='Bad access token', type='OAuthException', code=190))) def test_error_response(self, *mocks): with LogMock('friends.utils.base', 'friends.protocols.facebook') as log_mock: self.assertRaises( FriendsError, self.protocol.home, ) contents = log_mock.empty(trim=False) self.assertEqual(contents, 'Logging in to Facebook\n') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'facebook-full.dat')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.protocols.facebook.Facebook._login', return_value=True) def test_receive(self, *mocks): # Receive the wall feed for a user. self.maxDiff = None self.account.access_token = 'abc' self.assertEqual(self.protocol.receive(), 12) self.assertEqual(TestModel.get_n_rows(), 12) self.assertEqual(list(TestModel.get_row(0)), [ 'facebook', 88, 'userid_postid1', 'mentions', 'Yours Truly', '56789', 'Yours Truly', False, '2013-03-13T23:29:07Z', 'Writing code that supports geotagging data from facebook. ' + 'If y\'all could make some geotagged facebook posts for me ' + 'to test with, that\'d be super.', 'https://graph.facebook.com/56789/picture?width=840&height=840', 'https://www.facebook.com/56789/posts/postid1', 1, False, '', '', '', '', '', '', 'Victoria, British Columbia', 48.4333, -123.35, ]) self.assertEqual(list(TestModel.get_row(2)), [ 'facebook', 88, 'postid1_commentid2', 'reply_to/userid_postid1', 'Father', '234', 'Father', False, '2013-03-12T23:29:45Z', 'don\'t know how', 'https://graph.facebook.com/234/picture?width=840&height=840', 'https://www.facebook.com/234/posts/commentid2', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ]) self.assertEqual(list(TestModel.get_row(6)), [ 'facebook', 88, '161247843901324_629147610444676', 'images', 'Best Western Denver Southwest', '161247843901324', 'Best Western Denver Southwest', False, '2013-03-11T23:51:25Z', 'Today only -- Come meet Caroline and Meredith and Stanley the ' + 'Stegosaurus (& Greg & Joe, too!) at the TechZulu Trend Lounge, ' + 'Hilton Garden Inn 18th floor, 500 N Interstate 35, Austin, ' + 'Texas. Monday, March 11th, 4:00pm to 7:00 pm. Also here ' + 'Hannah Hart (My Drunk Kitchen) and Angry Video Game Nerd ' + 'producer, Sean Keegan. Stanley is in the lobby.', 'https://graph.facebook.com/161247843901324/picture?width=840&height=840', 'https://www.facebook.com/161247843901324/posts/629147610444676', 84, False, 'http://graph.facebook.com/629147587111345/picture?type=normal', '', 'https://www.facebook.com/photo.php?fbid=629147587111345&set=a.173256162700492.47377.161247843901324&type=1&relevant_count=1', '', '', '', 'Hilton Garden Inn Austin Downtown/Convention Center', 30.265384957204, -97.735604602521, ]) self.assertEqual(list(TestModel.get_row(9)), [ 'facebook', 88, '104443_100085049977', 'mentions', 'Guy Frenchie', '1244414', 'Guy Frenchie', False, '2013-03-15T19:57:14Z', 'Guy Frenchie did some things with some stuff.', 'https://graph.facebook.com/1244414/picture?width=840&height=840', 'https://www.facebook.com/1244414/posts/100085049977', 3, False, '', '', '', '', '', '', '', 0.0, 0.0, ]) # XXX We really need full coverage of the receive() method, including # cases where some data is missing, or can't be converted # (e.g. timestamps), and paginations. @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'facebook-full.dat')) @mock.patch('friends.protocols.facebook.Facebook._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_home_since_id(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.assertEqual(self.protocol.home(), 12) with open(self._root.format('facebook_ids'), 'r') as fd: self.assertEqual(fd.read(), '{"messages": "2013-03-15T19:57:14Z"}') follow = self.protocol._follow_pagination = mock.Mock() follow.return_value = [] self.assertEqual(self.protocol.home(), 12) follow.assert_called_once_with( 'https://graph.facebook.com/me/home', dict(limit=50, since='2013-03-15T19:57:14Z', access_token='access', ) ) @mock.patch('friends.protocols.facebook.Downloader') def test_send_to_my_wall(self, dload): dload().get_json.return_value = dict(id='post_id') token = self.protocol._get_access_token = mock.Mock( return_value='face') publish = self.protocol._publish_entry = mock.Mock( return_value='http://facebook.com/post_id') self.assertEqual( self.protocol.send('I can see the writing on my wall.'), 'http://facebook.com/post_id') token.assert_called_once_with() publish.assert_called_with(entry={'id': 'post_id'}, stream='messages') self.assertEqual( dload.mock_calls, [mock.call(), mock.call('https://graph.facebook.com/me/feed', method='POST', params=dict( access_token='face', message='I can see the writing on my wall.')), mock.call().get_json(), mock.call('https://graph.facebook.com/post_id', params=dict(access_token='face')), mock.call().get_json() ]) @mock.patch('friends.protocols.facebook.Downloader') def test_send_to_my_friends_wall(self, dload): dload().get_json.return_value = dict(id='post_id') token = self.protocol._get_access_token = mock.Mock( return_value='face') publish = self.protocol._publish_entry = mock.Mock( return_value='http://facebook.com/new_post_id') self.assertEqual( self.protocol.send('I can see the writing on my friend\'s wall.', 'friend_id'), 'http://facebook.com/new_post_id') token.assert_called_once_with() publish.assert_called_with(entry={'id': 'post_id'}, stream='messages') self.assertEqual( dload.mock_calls, [mock.call(), mock.call( 'https://graph.facebook.com/friend_id/feed', method='POST', params=dict( access_token='face', message='I can see the writing on my friend\'s wall.')), mock.call().get_json(), mock.call('https://graph.facebook.com/post_id', params=dict(access_token='face')), mock.call().get_json(), ]) @mock.patch('friends.protocols.facebook.Downloader') def test_send_thread(self, dload): dload().get_json.return_value = dict(id='comment_id') token = self.protocol._get_access_token = mock.Mock( return_value='face') publish = self.protocol._publish_entry = mock.Mock( return_value='http://facebook.com/private_message_id') self.assertEqual( self.protocol.send_thread('post_id', 'Some witty response!'), 'http://facebook.com/private_message_id') token.assert_called_once_with() publish.assert_called_with(entry={'id': 'comment_id'}, stream='reply_to/post_id') self.assertEqual( dload.mock_calls, [mock.call(), mock.call( 'https://graph.facebook.com/post_id/comments', method='POST', params=dict( access_token='face', message='Some witty response!')), mock.call().get_json(), mock.call('https://graph.facebook.com/comment_id', params=dict(access_token='face')), mock.call().get_json(), ]) @mock.patch('friends.protocols.facebook.Uploader.get_json', return_value=dict(post_id='234125')) @mock.patch('friends.protocols.facebook.time.time', return_value=1352209748.1254) def test_upload_local(self, *mocks): token = self.protocol._get_access_token = mock.Mock( return_value='face') publish = self.protocol._publish = mock.Mock() src = 'file://' + resource_filename('friends.tests.data', 'ubuntu.png') self.assertEqual(self.protocol.upload(src, 'This is Ubuntu!'), 'https://www.facebook.com/234125') token.assert_called_once_with() publish.assert_called_once_with( sender_nick=None, stream='images', url='https://www.facebook.com/234125', timestamp='2012-11-06T13:49:08Z', sender_id=None, from_me=True, icon_uri='https://graph.facebook.com/None/picture?type=large', message='This is Ubuntu!', message_id='234125', sender=None) @mock.patch('friends.utils.http._soup') @mock.patch('friends.protocols.facebook.Uploader._build_request', return_value=None) @mock.patch('friends.protocols.facebook.time.time', return_value=1352209748.1254) def test_upload_missing(self, *mocks): token = self.protocol._get_access_token = mock.Mock( return_value='face') publish = self.protocol._publish = mock.Mock() src = 'file:///tmp/a/non-existant/path' self.assertRaises( ValueError, self.protocol.upload, src, 'There is no spoon', ) token.assert_called_once_with() self.assertFalse(publish.called) @mock.patch('friends.utils.http._soup') def test_upload_not_uri(self, *mocks): token = self.protocol._get_access_token = mock.Mock( return_value='face') publish = self.protocol._publish = mock.Mock() src = resource_filename('friends.tests.data', 'ubuntu.png') self.assertRaises( GLib.GError, self.protocol.upload, src, 'There is no spoon', ) token.assert_called_once_with() self.assertFalse(publish.called) def test_search(self): self.protocol._get_access_token = lambda: '12345' get_pages = self.protocol._follow_pagination = mock.Mock( return_value=['search results']) publish = self.protocol._publish_entry = mock.Mock() self.assertEqual(self.protocol.search('hello'), 1) publish.assert_called_with('search results', 'search/hello') get_pages.assert_called_with( 'https://graph.facebook.com/search', dict(q='hello', access_token='12345')) @mock.patch('friends.protocols.facebook.Downloader') def test_like(self, dload): dload().get_json.return_value = True token = self.protocol._get_access_token = mock.Mock( return_value='face') inc_cell = self.protocol._inc_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.like('post_id'), 'post_id') inc_cell.assert_called_once_with('post_id', 'likes') set_cell.assert_called_once_with('post_id', 'liked', True) token.assert_called_once_with() dload.assert_called_with( 'https://graph.facebook.com/post_id/likes', method='POST', params=dict(access_token='face')) @mock.patch('friends.protocols.facebook.Downloader') def test_unlike(self, dload): dload.get_json.return_value = True token = self.protocol._get_access_token = mock.Mock( return_value='face') dec_cell = self.protocol._dec_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.unlike('post_id'), 'post_id') dec_cell.assert_called_once_with('post_id', 'likes') set_cell.assert_called_once_with('post_id', 'liked', False) token.assert_called_once_with() dload.assert_called_once_with( 'https://graph.facebook.com/post_id/likes', method='DELETE', params=dict(access_token='face')) @mock.patch('friends.protocols.facebook.Downloader') def test_delete(self, dload): dload().get_json.return_value = True token = self.protocol._get_access_token = mock.Mock( return_value='face') unpublish = self.protocol._unpublish = mock.Mock() self.assertEqual(self.protocol.delete('post_id'), 'post_id') token.assert_called_once_with() dload.assert_called_with( 'https://graph.facebook.com/post_id', method='DELETE', params=dict(access_token='face')) unpublish.assert_called_once_with('post_id') @mock.patch('friends.protocols.facebook.Downloader') def test_contacts(self, downloader): downloader().get_json.return_value = dict( name='Joe Blow', username='jblow', link='example.com', gender='male') downloader.reset_mock() self.protocol._get_access_token = mock.Mock(return_value='broken') follow = self.protocol._follow_pagination = mock.Mock( return_value=[dict(id='contact1'), dict(id='contact2')]) prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False) push = self.protocol._push_to_eds = mock.Mock() self.assertEqual(self.protocol.contacts(), 2) follow.assert_called_once_with( params={'access_token': 'broken', 'limit': 1000}, url='https://graph.facebook.com/me/friends', limit=1000) self.assertEqual( prev.call_args_list, [mock.call('contact1'), mock.call('contact2')]) self.assertEqual( downloader.call_args_list, [mock.call(url='https://graph.facebook.com/contact1', params={'access_token': 'broken'}), mock.call(url='https://graph.facebook.com/contact2', params={'access_token': 'broken'})]) self.assertEqual( push.call_args_list, [mock.call(gender='male', jabber='-contact1@chat.facebook.com', nick='jblow', link='example.com', name='Joe Blow', uid='contact1'), mock.call(gender='male', jabber='-contact2@chat.facebook.com', nick='jblow', link='example.com', name='Joe Blow', uid='contact2')]) def test_create_contact(self, *mocks): # Receive the users friends. eds_contact = self.protocol._create_contact( uid='555555555', name='Lucy Baron', nick='lucy.baron5', gender='female', link='http:www.facebook.com/lucy.baron5', jabber='-555555555@chat.facebook.com') facebook_id_attr = eds_contact.get_attribute('facebook-id') self.assertEqual(facebook_id_attr.get_value(), '555555555') web_service_addrs = eds_contact.get_attribute('X-FOLKS-WEB-SERVICES-IDS') params= web_service_addrs.get_params() self.assertEqual(len(params), 5) # Can't compare the vcard string directly because it is sorted randomly... vcard = eds_contact.to_string( EBookContacts.VCardFormat(1)).replace('\r\n ', '') self.assertIn( 'social-networking-attributes.X-URIS:http:www.facebook.com/lucy.baron5', vcard) self.assertIn( 'social-networking-attributes.X-GENDER:female', vcard) self.assertIn( 'social-networking-attributes.facebook-id:555555555', vcard) self.assertIn( 'FN:Lucy Baron', vcard) self.assertIn( 'NICKNAME:lucy.baron5', vcard) self.assertIn( 'social-networking-attributes.X-FOLKS-WEB-SERVICES-IDS;', vcard) self.assertIn( 'remote-full-name="Lucy Baron"', vcard) self.assertIn( 'facebook-id=555555555', vcard) self.assertIn( 'jabber="-555555555@chat.facebook.com"', vcard) self.assertIn( 'facebook-nick="lucy.baron5"', vcard) @mock.patch('friends.utils.base.Base._prepare_eds_connections', return_value=True) @mock.patch('gi.repository.EBook.BookClient.new', return_value=EDSBookClientMock()) def test_successfull_push_to_eds(self, *mocks): bare_contact = {'name': 'Lucy Baron', 'uid': '555555555', 'nick': 'lucy.baron5', 'link': 'http:www.facebook.com/lucy.baron5'} self.protocol._address_book = 'test-address-book' client = self.protocol._book_client = mock.Mock() client.add_contact_sync.return_value = True # Implicitely fail test if the following raises any exceptions self.protocol._push_to_eds(**bare_contact) @mock.patch('friends.utils.base.Base._prepare_eds_connections', return_value=None) def test_unsuccessfull_push_to_eds(self, *mocks): bare_contact = {'name': 'Lucy Baron', 'uid': '555555555', 'nick': 'lucy.baron5', 'link': 'http:www.facebook.com/lucy.baron5'} self.protocol._address_book = 'test-address-book' client = self.protocol._book_client = mock.Mock() client.add_contact_sync.return_value = False self.assertRaises( ContactsError, self.protocol._push_to_eds, **bare_contact ) @mock.patch('gi.repository.EBook.BookClient.connect_sync', return_value=EDSBookClientMock()) @mock.patch('gi.repository.EDataServer.SourceRegistry.new_sync', return_value=EDSRegistry()) def test_successful_previously_stored_contact(self, *mocks): result = self.protocol._previously_stored_contact('11111') self.assertEqual(result, True) @mock.patch('gi.repository.EBook.BookClient.connect_sync', return_value=EDSBookClientMock()) @mock.patch('gi.repository.EDataServer.SourceRegistry.new_sync', return_value=EDSRegistry()) def test_first_run_prepare_eds_connections(self, *mocks): self.protocol._name = 'testsuite' self.assertIsNone(self.protocol._address_book_name) self.assertIsNone(self.protocol._eds_source_registry) self.assertIsNone(self.protocol._eds_source) self.assertIsNone(self.protocol._book_client) self.protocol._prepare_eds_connections() self.assertEqual(self.protocol._address_book_name, 'friends-testsuite-contacts') self.assertEqual(self.protocol._eds_source.get_display_name(), 'friends-testsuite-contacts') self.assertEqual(self.protocol._eds_source.get_uid(), 'friends-testsuite-contacts') self.protocol.delete_contacts() @mock.patch('gi.repository.EDataServer.SourceRegistry') @mock.patch('gi.repository.EDataServer.Source') @mock.patch('gi.repository.EBook.BookClient') def test_mocked_prepare_eds_connections(self, client, source, registry): self.protocol._name = 'testsuite' self.assertIsNone(self.protocol._address_book_name) self.protocol._prepare_eds_connections() self.protocol._prepare_eds_connections() # Second time harmlessly ignored self.assertEqual(self.protocol._address_book_name, 'friends-testsuite-contacts') registry.new_sync.assert_called_once_with(None) self.assertEqual(self.protocol._eds_source_registry, registry.new_sync()) registry.new_sync().ref_source.assert_called_once_with( 'friends-testsuite-contacts') self.assertEqual(self.protocol._eds_source, registry.new_sync().ref_source()) client.connect_sync.assert_called_once_with( registry.new_sync().ref_source(), None) self.assertEqual(self.protocol._book_client, client.connect_sync()) @mock.patch('gi.repository.EDataServer.SourceRegistry') @mock.patch('gi.repository.EDataServer.Source') @mock.patch('gi.repository.EBook.BookClient') def test_create_new_eds_book(self, client, source, registry): self.protocol._name = 'testsuite' self.assertIsNone(self.protocol._address_book_name) registry.new_sync().ref_source.return_value = None registry.reset_mock() self.protocol._prepare_eds_connections() self.protocol._prepare_eds_connections() # Second time harmlessly ignored self.assertEqual(self.protocol._address_book_name, 'friends-testsuite-contacts') registry.new_sync.assert_called_once_with(None) self.assertEqual(self.protocol._eds_source_registry, registry.new_sync()) registry.new_sync().ref_source.assert_called_once_with( 'friends-testsuite-contacts') source.new_with_uid.assert_called_once_with( 'friends-testsuite-contacts', None) self.assertEqual(self.protocol._eds_source, source.new_with_uid()) source.new_with_uid().set_display_name.assert_called_once_with( 'friends-testsuite-contacts') source.new_with_uid().set_parent.assert_called_once_with('local-stub') source.new_with_uid().get_extension.assert_called_once_with( EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK) registry.new_sync().commit_source_sync.assert_called_once_with( source.new_with_uid(), None) client.connect_sync.assert_called_once_with( source.new_with_uid(), None) self.assertEqual(self.protocol._book_client, client.connect_sync()) friends-0.2.0+14.04.20140217.1/friends/tests/test_menu.py0000644000015201777760000000317012300444435023050 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the MenuManager class.""" __all__ = [ 'TestMenu', ] import unittest from friends.utils.menus import MenuManager from friends.tests.mocks import mock def callback_stub(*ignore): pass class TestMenu(unittest.TestCase): """Test MenuManager class.""" def setUp(self): self.menu = MenuManager(callback_stub, callback_stub) self.menu.messaging = mock.Mock() def test_unread_count_visible(self): # Calling update_unread_count() with a non-zero value will make the # count visible. self.menu.update_unread_count(42) self.menu.messaging.set_source_count.assert_called_once_with( 'unread', 42) def test_unread_count_invisible(self): # Calling update_unread_count() with a zero value will make the count # invisible. self.menu.update_unread_count(0) self.menu.messaging.remove_source.assert_called_once_with( 'unread') friends-0.2.0+14.04.20140217.1/friends/tests/test_shortener.py0000644000015201777760000001362012300444435024116 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the URL shorteners.""" __all__ = [ 'TestShorteners', ] import unittest from friends.utils.shorteners import Short from friends.tests.mocks import FakeSoupMessage, mock @mock.patch('friends.utils.http._soup', mock.Mock()) class TestShorteners(unittest.TestCase): """Test the various shorteners, albeit via mocks.""" @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'short.dat')) def test_isgd(self): self.assertEqual( Short('is.gd').make('http://www.python.org'), 'http://sho.rt/') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'short.dat')) def test_ougd(self): self.assertEqual( Short('ou.gd').make('http://www.python.org'), 'http://sho.rt/') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'short.dat')) def test_linkeecom(self): self.assertEqual( Short('linkee.com').make('http://www.python.org'), 'http://sho.rt/') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'short.dat')) def test_tinyurlcom(self): self.assertEqual( Short('tinyurl.com').make('http://www.python.org'), 'http://sho.rt/') @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'durlme.dat')) def test_durlme(self): self.assertEqual( Short('durl.me').make('http://www.python.org'), 'http://durl.me/5o') def test_missing_or_disabled_lookup(self): # Looking up a non-existent or disabled shortener gives you one that # returns the original url back unchanged. self.assertEqual( Short('nonexistant').make('http://www.python.org'), 'http://www.python.org') self.assertEqual( Short().make('http://www.python.org'), 'http://www.python.org') def test_is_shortened(self): # Test URLs that have been shortened. self.assertTrue(Short.already('http://tinyurl.com/foo')) self.assertTrue(Short.already('http://is.gd/foo')) self.assertTrue(Short.already('http://linkee.com/foo')) self.assertTrue(Short.already('http://ou.gd/foo')) self.assertTrue(Short.already('http://durl.me/foo')) def test_is_not_shortened(self): # Test a URL that has not been shortened. self.assertFalse(Short.already('http://www.python.org/bar')) @mock.patch('friends.utils.shorteners.Downloader') def test_isgd_quoted_properly(self, dl_mock): Short('is.gd').make('http://example.com/~user/stuff/+things') dl_mock.assert_called_once_with( 'http://is.gd/api.php?longurl=http%3A%2F%2Fexample.com' '%2F%7Euser%2Fstuff%2F%2Bthings') @mock.patch('friends.utils.shorteners.Downloader') def test_ougd_quoted_properly(self, dl_mock): Short('ou.gd').make('http://example.com/~user/stuff/+things') dl_mock.assert_called_once_with( 'http://ou.gd/api.php?format=simple&action=shorturl&url=' 'http%3A%2F%2Fexample.com%2F%7Euser%2Fstuff%2F%2Bthings') @mock.patch('friends.utils.shorteners.Downloader') def test_linkeecom_quoted_properly(self, dl_mock): Short('linkee.com').make( 'http://example.com/~user/stuff/+things') dl_mock.assert_called_once_with( 'http://api.linkee.com/1.0/shorten?format=text&input=' 'http%3A%2F%2Fexample.com%2F%7Euser%2Fstuff%2F%2Bthings') @mock.patch('friends.utils.shorteners.Downloader') def test_tinyurl_quoted_properly(self, dl_mock): Short('tinyurl.com').make( 'http://example.com/~user/stuff/+things') dl_mock.assert_called_once_with( 'http://tinyurl.com/api-create.php?url=http%3A%2F%2Fexample.com' '%2F%7Euser%2Fstuff%2F%2Bthings') @mock.patch('friends.utils.shorteners.Downloader') def test_durlme_quoted_properly(self, dl_mock): dl_mock().get_string().strip.return_value = '' dl_mock.reset_mock() Short('durl.me').make( 'http://example.com/~user/stuff/+things') dl_mock.assert_called_once_with( 'http://durl.me/api/Create.do?type=json&longurl=' 'http%3A%2F%2Fexample.com%2F%7Euser%2Fstuff%2F%2Bthings') @mock.patch('friends.utils.shorteners.Downloader') def test_dont_over_shorten(self, dl_mock): Short('tinyurl.com').make('http://tinyurl.com/page_id') Short('linkee.com').make('http://ou.gd/page_id') Short('is.gd').make('http://is.gd/page_id') Short('ou.gd').make('http://linkee.com/page_id') self.assertEqual(dl_mock.call_count, 0) def test_find_all_in_string(self): shorter = Short() shorter.make = lambda url: 'zombo.com' self.assertEqual( 'Welcome to zombo.com, anything is possible. ' 'You can do anything at zombo.com!', shorter.sub( 'Welcome to http://example.com/really/really/long/url, ' 'anything is possible. You can do anything at ' 'http://example.com!')) friends-0.2.0+14.04.20140217.1/friends/tests/test_protocols.py0000644000015201777760000005052612300444435024137 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test generic protocol support.""" __all__ = [ 'TestProtocolManager', 'TestProtocols', ] import unittest import threading from friends.protocols.flickr import Flickr from friends.protocols.twitter import Twitter from friends.tests.mocks import SCHEMA, FakeAccount, LogMock, TestModel, mock from friends.utils.base import Base, feature, linkify_string from friends.utils.manager import ProtocolManager from friends.utils.model import Model class TestProtocolManager(unittest.TestCase): """Test the protocol finder.""" def setUp(self): self.manager = ProtocolManager() self.log_mock = LogMock('friends.utils.base') def tearDown(self): self.log_mock.stop() def test_find_all_protocols(self): # The manager can find all the protocol classes. self.assertEqual(self.manager.protocols['flickr'], Flickr) self.assertEqual(self.manager.protocols['twitter'], Twitter) def test_doesnt_find_base(self): # Make sure that the base protocol class isn't returned. self.assertNotIn('base', self.manager.protocols) for protocol_class in self.manager.protocols.values(): self.assertNotEqual(protocol_class, Base) class MyProtocol(Base): """Simplest possible protocol implementation to allow testing of Base.""" def noop(self, one=None, two=None): return '{}:{}'.format(one, two) # A non-public method that cannot be called through __call__() _private = noop def _locked_login(self, old_token): self._account.access_token = 'fake_token' # Two features and two non-features for testing purposes. @feature def feature_1(self): pass @feature def feature_2(self): pass def non_feature_1(self): pass def non_feature_2(self): pass @mock.patch('friends.utils.base.notify', mock.Mock()) class TestProtocols(unittest.TestCase): """Test protocol implementations.""" def setUp(self): TestModel.clear() def test_no_operation(self): # Trying to call a protocol with a missing operation raises an # AttributeError exception. fake_account = object() my_protocol = MyProtocol(fake_account) with self.assertRaises(NotImplementedError) as cm: my_protocol('give_me_a_pony') self.assertEqual(str(cm.exception), 'give_me_a_pony') def test_private_operation(self): # Trying to call a protocol with a non-public operation raises an # AttributeError exception. fake_account = object() my_protocol = MyProtocol(fake_account) # We can call the method directly. result = my_protocol._private('ant', 'bee') self.assertEqual(result, 'ant:bee') # But we cannot call the method through the __call__ interface. with self.assertRaises(NotImplementedError) as cm: my_protocol('_private', 'cat', 'dog') self.assertEqual(str(cm.exception), '_private') def test_basic_api_synchronous(self): # Protocols get instantiated with an account, and the instance gets # called to perform an operation. fake_account = object() my_protocol = MyProtocol(fake_account) result = my_protocol.noop(one='foo', two='bar') self.assertEqual(result, 'foo:bar') def test_basic_api_asynchronous(self): fake_account = object() my_protocol = MyProtocol(fake_account) success = mock.Mock() failure = mock.Mock() # Using __call__ makes invocation happen asynchronously in a thread. my_protocol('noop', 'one', 'two', success=success, failure=failure) for thread in threading.enumerate(): # Join all but the main thread. if thread != threading.current_thread(): thread.join() success.assert_called_once_with('one:two') self.assertEqual(failure.call_count, 0) @mock.patch('friends.utils.base.Model', TestModel) def test_shared_model_successfully_mocked(self): count = Model.get_n_rows() self.assertEqual(TestModel.get_n_rows(), 0) base = Base(FakeAccount()) base._publish(message_id='alpha', message='a') base._publish(message_id='beta', message='b') base._publish(message_id='omega', message='c') self.assertEqual(Model.get_n_rows(), count) self.assertEqual(TestModel.get_n_rows(), 3) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_seen_dicts_successfully_instantiated(self): from friends.utils.base import _seen_ids from friends.utils.base import initialize_caches self.assertEqual(TestModel.get_n_rows(), 0) base = Base(FakeAccount()) base._publish(message_id='alpha', sender='a', message='a') base._publish(message_id='beta', sender='a', message='a') base._publish(message_id='omega', sender='a', message='b') self.assertEqual(TestModel.get_n_rows(), 3) _seen_ids.clear() initialize_caches() self.assertEqual( _seen_ids, dict(alpha=0, beta=1, omega=2, ) ) @mock.patch('friends.utils.base.Model', TestModel) def test_invalid_argument(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) with self.assertRaises(TypeError) as cm: base._publish(message_id='message_id', invalid_argument='not good') self.assertEqual(str(cm.exception), 'Unexpected keyword arguments: invalid_argument') @mock.patch('friends.utils.base.Model', TestModel) def test_invalid_arguments(self): # All invalid arguments are mentioned in the exception message. base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) with self.assertRaises(TypeError) as cm: base._publish(message_id='p.middy', bad='no', wrong='yes') self.assertEqual(str(cm.exception), 'Unexpected keyword arguments: bad, wrong') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_one_message(self): # Test that publishing a message inserts a row into the model. base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish( message_id='1234', stream='messages', sender='fred', sender_nick='freddy', from_me=True, timestamp='today', message='hello, @jimmy', likes=10, liked=True)) self.assertEqual(1, TestModel.get_n_rows()) row = TestModel.get_row(0) self.assertEqual( list(row), ['base', 88, '1234', 'messages', 'fred', '', 'freddy', True, 'today', 'hello, @jimmy', '', '', 10, True, '', '', '', '', '', '', '', 0.0, 0.0, ]) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_unpublish(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish( message_id='1234', sender='fred', message='hello, @jimmy')) self.assertTrue(base._publish( message_id='1234', sender='fred', message='hello, @jimmy')) self.assertTrue(base._publish( message_id='5678', sender='fred', message='hello, +jimmy')) self.assertEqual(2, TestModel.get_n_rows()) base._unpublish('1234') self.assertEqual(1, TestModel.get_n_rows()) base._unpublish('5678') self.assertEqual(0, TestModel.get_n_rows()) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_duplicate_messages_identified(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish( message_id='1234', stream='messages', sender='fred', sender_nick='freddy', from_me=True, timestamp='today', message='hello, @jimmy', likes=10, liked=True)) # Duplicate self.assertTrue(base._publish( message_id='1234', stream='messages', sender='fred', sender_nick='freddy', from_me=True, timestamp='today', message='hello jimmy', likes=10, liked=False)) # See, we get only one row in the table. self.assertEqual(1, TestModel.get_n_rows()) # The first published message wins. row = TestModel.get_row(0) self.assertEqual(row[SCHEMA.INDICES['message']], 'hello, @jimmy') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_duplicate_ids_not_duplicated(self): # When two messages are actually identical (same ids and all), # we need to avoid duplicating the id in the sharedmodel. base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish( message_id='1234', stream='messages', sender='fred', message='hello, @jimmy')) self.assertTrue(base._publish( message_id='1234', stream='messages', sender='fred', message='hello, @jimmy')) self.assertEqual(1, TestModel.get_n_rows()) row = TestModel.get_row(0) self.assertEqual( list(row), ['base', 88, '1234', 'messages', 'fred', '', '', False, '', 'hello, @jimmy', '', '', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ]) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_similar_messages_allowed(self): # Because both the sender and message contribute to the unique key we # use to identify messages, if two messages are published with # different senders, both are inserted into the table. base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) # The key for this row is 'fredhellojimmy' self.assertTrue(base._publish( message_id='1234', stream='messages', sender='fred', sender_nick='freddy', from_me=True, timestamp='today', message='hello, @jimmy', likes=10, liked=True)) self.assertEqual(1, TestModel.get_n_rows()) # The key for this row is 'tedtholomewhellojimmy' self.assertTrue(base._publish( message_id='34567', stream='messages', sender='tedtholomew', sender_nick='teddy', from_me=False, timestamp='today', message='hello, @jimmy', likes=10, liked=True)) # See? Two rows in the table. self.assertEqual(2, TestModel.get_n_rows()) # The first row is the message from fred. self.assertEqual(TestModel.get_row(0)[SCHEMA.INDICES['sender']], 'fred') # The second row is the message from tedtholomew. self.assertEqual(TestModel.get_row(1)[SCHEMA.INDICES['sender']], 'tedtholomew') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_inc_cell(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish(message_id='1234', likes=10, liked=True)) base._inc_cell('1234', 'likes') row = TestModel.get_row(0) self.assertEqual( list(row), ['base', 88, '1234', '', '', '', '', False, '', '', '', '', 11, True, '', '', '', '', '', '', '', 0.0, 0.0]) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_dec_cell(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish(message_id='1234', likes=10, liked=True)) base._dec_cell('1234', 'likes') row = TestModel.get_row(0) self.assertEqual( list(row), ['base', 88, '1234', '', '', '', '', False, '', '', '', '', 9, True, '', '', '', '', '', '', '', 0.0, 0.0]) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_set_cell(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish(message_id='1234', likes=10, liked=True)) base._set_cell('1234', 'likes', 500) base._set_cell('1234', 'liked', False) base._set_cell('1234', 'message_id', '5678') row = TestModel.get_row(0) self.assertEqual( list(row), ['base', 88, '5678', '', '', '', '', False, '', '', '', '', 500, False, '', '', '', '', '', '', '', 0.0, 0.0]) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_fetch_cell(self): base = Base(FakeAccount()) self.assertEqual(0, TestModel.get_n_rows()) self.assertTrue(base._publish(message_id='1234', likes=10, liked=True)) self.assertEqual(base._fetch_cell('1234', 'likes'), 10) def test_basic_login(self): # Try to log in twice. The second login attempt returns False because # it's already logged in. my_protocol = MyProtocol(FakeAccount()) self.assertTrue(my_protocol._login()) self.assertFalse(my_protocol._login()) def test_failing_login(self): # The first login attempt fails because _locked_login() does not set # an access token. class FailingProtocol(MyProtocol): def _locked_login(self, old_token): pass my_protocol = FailingProtocol(FakeAccount()) self.assertFalse(my_protocol._login()) # XXX I think there's a threading test that should be performed, but it # hurts my brain too much. See the comment at the bottom of the # with-statement in Base._login(). def test_features(self): self.assertEqual(MyProtocol.get_features(), ['delete_contacts', 'feature_1', 'feature_2']) def test_linkify_string(self): # String with no URL is unchanged. self.assertEqual('Hello!', linkify_string('Hello!')) # http:// works. self.assertEqual( 'http://www.example.com', linkify_string('http://www.example.com')) # https:// works, too. self.assertEqual( 'https://www.example.com', linkify_string('https://www.example.com')) # http:// is optional if you include www. self.assertEqual( 'www.example.com', linkify_string('www.example.com')) # Haha, nobody uses ftp anymore! self.assertEqual( 'ftp://example.com/', linkify_string('ftp://example.com/')) # Trailing periods are not linkified. self.assertEqual( 'http://example.com.', linkify_string('http://example.com.')) # URL can contain periods without getting cut off. self.assertEqual( '' 'http://example.com/products/buy.html.', linkify_string('http://example.com/products/buy.html.')) # Don't linkify trailing brackets. self.assertEqual( 'Example Co (http://example.com).', linkify_string('Example Co (http://example.com).')) # Don't linkify trailing exclamation marks. self.assertEqual( 'Go to https://example.com!', linkify_string('Go to https://example.com!')) # Don't linkify trailing commas, also ensure all links are found. self.assertEqual( 'www.example.com, http://example.com/stuff, and ' 'http://example.com/things ' 'are my favorite sites.', linkify_string('www.example.com, http://example.com/stuff, and ' 'http://example.com/things are my favorite sites.')) # Don't linkify trailing question marks. self.assertEqual( 'Ever been to www.example.com?', linkify_string('Ever been to www.example.com?')) # URLs can contain question marks ok. self.assertEqual( 'Like ' 'http://example.com?foo=bar&grill=true?', linkify_string('Like http://example.com?foo=bar&grill=true?')) # URLs can contain encoded spaces and parentheses. self.assertEqual( '' 'http://example.com/foo%20(3).JPG!', linkify_string('http://example.com/foo%20(3).JPG!')) # Multi-line strings are also supported. self.assertEqual( 'Hey, visit us online!\n\n' 'http://example.com', linkify_string('Hey, visit us online!\n\nhttp://example.com')) # Don't accidentally duplicate linkification. self.assertEqual( 'click here!', linkify_string('click here!')) self.assertEqual( 'www.example.com', linkify_string('www.example.com')) self.assertEqual( 'www.example.com is our website', linkify_string( 'www.example.com is our website')) self.assertEqual( "www.example.com is our website", linkify_string( "www.example.com is our website")) # This, apparently, is valid HTML. self.assertEqual( 'www.example.com', linkify_string( 'www.example.com')) # Pump.io is throwing doctypes at us! self.assertEqual( '', linkify_string( '')) friends-0.2.0+14.04.20140217.1/friends/tests/test_cache.py0000644000015201777760000000513012300444435023145 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the JSON cacher.""" __all__ = [ 'TestJsonCache', ] import os import shutil import tempfile import unittest from pkg_resources import resource_filename from friends.utils.cache import JsonCache class TestJsonCache(unittest.TestCase): """Test JsonCache logic.""" def setUp(self): self._temp_cache = tempfile.mkdtemp() self._root = JsonCache._root = os.path.join( self._temp_cache, '{}.json') def tearDown(self): # Clean up the temporary cache directory. shutil.rmtree(self._temp_cache) def test_creation(self): cache = JsonCache('foo') with open(self._root.format('foo'), 'r') as fd: empty = fd.read() self.assertEqual(empty, '{}') def test_values(self): cache = JsonCache('bar') cache['hello'] = 'world' with open(self._root.format('bar'), 'r') as fd: result = fd.read() self.assertEqual(result, '{"hello": "world"}') def test_writes(self): cache = JsonCache('stuff') cache.update(dict(pi=289/92)) with open(self._root.format('stuff'), 'r') as fd: empty = fd.read() self.assertEqual(empty, '{}') cache.write() with open(self._root.format('stuff'), 'r') as fd: result = fd.read() self.assertEqual(result, '{"pi": 3.141304347826087}') def test_invalid_json(self): shutil.copyfile( resource_filename('friends.tests.data', 'facebook_ids_not.json'), os.path.join(self._temp_cache, 'invalid.json')) cache = JsonCache('invalid') self.assertEqual(repr(cache), '{}') def test_total_corruption(self): shutil.copyfile( resource_filename('friends.tests.data', 'facebook_ids_corrupt.json'), os.path.join(self._temp_cache, 'corrupt.json')) cache = JsonCache('corrupt') self.assertEqual(repr(cache), '{}') friends-0.2.0+14.04.20140217.1/friends/tests/test_linkedin.py0000644000015201777760000001353712300444435023711 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the LinkedIn plugin.""" __all__ = [ 'TestLinkedIn', ] import unittest from friends.protocols.linkedin import LinkedIn, make_fullname from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock from friends.tests.mocks import TestModel, mock from friends.errors import AuthorizationError @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) class TestLinkedIn(unittest.TestCase): """Test the LinkedIn API.""" def setUp(self): TestModel.clear() self.account = FakeAccount() self.protocol = LinkedIn(self.account) self.log_mock = LogMock('friends.utils.base', 'friends.protocols.linkedin') def tearDown(self): # Ensure that any log entries we haven't tested just get consumed so # as to isolate out test logger from other tests. self.log_mock.stop() def test_name_logic(self): self.assertEqual('', make_fullname()) self.assertEqual('', make_fullname(irrelevant_key='foo')) self.assertEqual('Bob', make_fullname(**dict(firstName='Bob'))) self.assertEqual('LastOnly', make_fullname(**dict(lastName='LastOnly'))) self.assertEqual( 'Bob Loblaw', make_fullname(**dict(firstName='Bob', lastName='Loblaw'))) self.assertEqual( 'Bob Loblaw', make_fullname(**dict(firstName='Bob', lastName='Loblaw', extra='ignored'))) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') @mock.patch('friends.protocols.linkedin.Downloader.get_json', return_value=None) def test_unsuccessful_authentication(self, dload, login, *mocks): self.assertRaises(AuthorizationError, self.protocol._login) self.assertIsNone(self.account.user_name) self.assertIsNone(self.account.user_id) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='some clever fake data')) @mock.patch('friends.protocols.linkedin.Downloader.get_json', return_value=dict(id='blerch', firstName='Bob', lastName='Loblaw')) def test_successful_authentication(self, *mocks): self.assertTrue(self.protocol._login()) self.assertEqual(self.account.user_name, 'Bob Loblaw') self.assertEqual(self.account.user_id, 'blerch') self.assertEqual(self.account.access_token, 'some clever fake data') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'linkedin_receive.json')) @mock.patch('friends.protocols.linkedin.LinkedIn._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_home(self, *mocks): self.account.access_token = 'access' self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual(self.protocol.home(), 1) self.assertEqual(1, TestModel.get_n_rows()) self.maxDiff = None self.assertEqual( list(TestModel.get_row(0)), ['linkedin', 88, 'UNIU-73705-576270369559388-SHARE', 'messages', 'Hobson L', 'ma0LLid', '', False, '2013-07-16T00:47:06Z', 'I\'m looking forward to the Udacity Global meetup event here in ' 'Portland: http://lnkd.in/dh5MQz' '\nGreat way to support the next big thing in c…', 'http://m.c.lnkd.licdn.com/mpr/mprx/0_mVxsC0BnN52aqc24yWvoyA5haqc2Z' 'wLCgzLv0EiBGp7n2jTwX-ls_dzgkSVIZu0', 'https://www.linkedin.com/profile/view?id=7375&authType=name' '&authToken=-LNy&trk=api*a26127*s26893*', 1, False, '', '', '', '', '', '', '', 0.0, 0.0]) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'linkedin_contacts.json')) @mock.patch('friends.protocols.linkedin.LinkedIn._login', return_value=True) def test_contacts(self, *mocks): push = self.protocol._push_to_eds = mock.Mock() prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False) token = self.protocol._get_access_token = mock.Mock(return_value='foo') self.protocol._create_contact = lambda arg:arg self.assertEqual(self.protocol.contacts(), 4) self.assertEqual( push.mock_calls, [mock.call(link='https://www.linkedin.com', name='H A', uid='IFDI'), mock.call(link='https://www.linkedin.com', name='C A', uid='AefF'), mock.call(link='https://www.linkedin.com', name='R A', uid='DFdV'), mock.call(link='https://www.linkedin.com', name='A Z', uid='xkBU')]) friends-0.2.0+14.04.20140217.1/friends/tests/test_download.py0000644000015201777760000002426112300444435023717 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the downloading utilities.""" __all__ = [ 'TestDownloader', ] import json import time import datetime import unittest import threading from urllib.error import URLError from urllib.parse import parse_qs from urllib.request import urlopen from wsgiref.simple_server import WSGIRequestHandler, make_server from wsgiref.util import setup_testing_defaults from pkg_resources import resource_filename from friends.tests.mocks import FakeSoupMessage, LogMock, mock from friends.utils.http import Downloader, Uploader class _SilentHandler(WSGIRequestHandler): def log_message(self, format, *args): pass def _app(environ, start_response): """WSGI application for responding to test queries.""" setup_testing_defaults(environ) status = '200 OK' results = [] headers = [('Content-Type', 'text/plain; charset=utf-8')] path = environ['PATH_INFO'] if path == '/ping': pass elif path == '/mirror': size = int(environ['CONTENT_LENGTH']) results = [environ['wsgi.input'].read(size)] elif path == '/json': results = [json.dumps(dict(answer='hello')).encode('utf-8')] elif path == '/post': # Might be a GET or POST. if environ['REQUEST_METHOD'] == 'POST': size = int(environ['CONTENT_LENGTH']) payload = environ['wsgi.input'].read(size) params = parse_qs(payload) # We want a mapping from strings to ints, but the POST data will # be utf-8 encoded byte keys and lists of length 1 with byte # representations of integers. Do the conversion. converted = {key.decode('utf-8'): int(value[0]) for key, value in params.items()} else: assert environ['REQUEST_METHOD'] == 'GET' payload = parse_qs(environ['QUERY_STRING']) # We want a mapping from strings to ints, but the query string # will be strings and lists of length 1 with string representation # of integers. Do the conversion. converted = {key: int(value[0]) for key, value in payload.items()} results = [json.dumps(converted).encode('utf-8')] elif path == '/headers': http_headers = {} for key, value in environ.items(): if key.startswith('HTTP_X_'): http_headers[key[7:].lower()] = value results = [json.dumps(http_headers).encode('utf-8')] elif path == '/text': results = [b'hello world'] elif path == '/bytes': results = [bytes.fromhex('f157f00d')] else: status = '404 Bad' results = [b'Missing'] start_response(status, headers) return results class TestDownloader(unittest.TestCase): """Test the downloading utilities.""" def setUp(self): self.log_mock = LogMock('friends.utils.http') def tearDown(self): self.log_mock.stop() @classmethod def setUpClass(cls): cls.server = make_server('', 9180, _app, handler_class=_SilentHandler) cls.thread = threading.Thread(target=cls.server.serve_forever) cls.thread.start() # Wait until the server is responding. until = datetime.datetime.now() + datetime.timedelta(seconds=30) while datetime.datetime.now() < until: try: with urlopen('http://localhost:9180/ping'): pass except URLError: time.sleep(0.1) else: break else: raise RuntimeError('Server thread did not start up.') @classmethod def tearDownClass(cls): cls.server.shutdown() cls.thread.join() def test_simple_json_download(self): # Test simple downloading of JSON data. self.assertEqual(Downloader('http://localhost:9180/json').get_json(), dict(answer='hello')) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'json-utf-8.dat', 'utf-8')) def test_json_explicit_utf_8(self): # RFC 4627 $3 with explicit charset=utf-8. self.assertEqual(Downloader('http://example.com').get_json(), dict(yes='ÑØ')) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'json-utf-8.dat', None)) def test_json_implicit_utf_8(self): # RFC 4627 $3 with implicit charset=utf-8. self.assertEqual(Downloader('http://example.com').get_json(), dict(yes='ÑØ')) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'json-utf-16le.dat', None)) def test_json_implicit_utf_16le(self): # RFC 4627 $3 with implicit charset=utf-16le. self.assertEqual(Downloader('http://example.com').get_json(), dict(yes='ÑØ')) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'json-utf-16be.dat', None)) def test_json_implicit_utf_16be(self): # RFC 4627 $3 with implicit charset=utf-16be. self.assertEqual(Downloader('http://example.com').get_json(), dict(yes='ÑØ')) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'json-utf-32le.dat', None)) def test_json_implicit_utf_32le(self): # RFC 4627 $3 with implicit charset=utf-32le. self.assertEqual(Downloader('http://example.com').get_json(), dict(yes='ÑØ')) @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'json-utf-32be.dat', None)) def test_json_implicit_utf_32be(self): # RFC 4627 $3 with implicit charset=utf-32be. self.assertEqual(Downloader('http://example.com').get_json(), dict(yes='ÑØ')) def test_simple_text_download(self): # Test simple downloading of text data. self.assertEqual(Downloader('http://localhost:9180/text').get_string(), 'hello world') def test_simple_bytes_download(self): # Test simple downloading of bytes data. bytes_data = Downloader('http://localhost:9180/bytes').get_bytes() self.assertIsInstance(bytes_data, bytes) self.assertEqual(list(bytes_data), [241, 87, 240, 13]) def test_params_post(self): # Test posting data. self.assertEqual( Downloader('http://localhost:9180/post', params=dict(one=1, two=2, three=3), method='POST').get_json(), dict(one=1, two=2, three=3)) def test_params_get(self): # Test getting with query string URL. self.assertEqual( Downloader('http://localhost:9180/post', params=dict(one=1, two=2, three=3), method='GET').get_json(), dict(one=1, two=2, three=3)) def test_headers(self): # Provide some additional headers. self.assertEqual( Downloader('http://localhost:9180/headers', headers={'X-Foo': 'baz', 'X-Bar': 'foo'}).get_json(), dict(foo='baz', bar='foo')) def test_mirror(self): self.assertEqual( Downloader('http://localhost:9180/mirror', method='POST', params=dict(foo='bar')).get_string(), 'foo=bar') def test_uploads(self): filename = resource_filename('friends.tests.data', 'ubuntu.png') raw_sent = Uploader( 'http://localhost:9180/mirror', 'file://' + filename, 'Great logo!', picture_key='source', desc_key='message', foo='bar', ).get_bytes() delimiter = raw_sent[2:66] self.assertTrue( raw_sent.startswith( b'--' + delimiter + b'\r\nContent-Disposition: form-data; ' b'name="foo"\r\n\r\nbar\r\n--' + delimiter + b'\r\nContent-Disposition: form-data; name="message"\r\n\r\n' b'Great logo!\r\n--' + delimiter + b'\r\nContent-Disposition: ' b'form-data; name="source"; filename="file://' + filename.encode() + b'"\r\nContent-Type: ' b'application/octet-stream\r\n\r\n\x89PNG')) self.assertTrue( raw_sent.endswith( b'\r\n--' + delimiter + b'--\r\n')) @mock.patch('friends.utils.http.Soup') @mock.patch('friends.utils.http._soup') def test_upload_happens_only_once(self, _soupmock, Soupmock): filename = resource_filename('friends.tests.data', 'ubuntu.png') Uploader( 'http://localhost:9180/mirror', 'file://' + filename, 'Great logo!', picture_key='source', desc_key='message', foo='bar', ).get_bytes() _soupmock.send_message.assert_called_once_with( Soupmock.form_request_new_from_multipart()) friends-0.2.0+14.04.20140217.1/friends/tests/data/0000755000015201777760000000000012300444701021377 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/tests/data/__init__.py0000644000015201777760000000000012300444435023502 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/tests/data/linkedin_receive.json0000644000015201777760000002473412300444435025607 0ustar pbusernogroup00000000000000{"_count": 10, "_start": 0, "_total": 23, "values": [{"isCommentable": true, "isLikable": true, "isLiked": false, "likes": {"_total": 1, "values": [{"person": {"firstName": "Tigran", "headline": "Software Engineer at Cornerstone OnDemand", "id": "6f7TDUv", "lastName": "K", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_zR-8KkCNtotClCYzyiPKFr9rqDrlCYBM60KFPQftJhzvOMjGuObFeOSfTn_tq4rx8jhPrK"}}]}, "numLikes": 1, "timestamp": 1373935626874, "updateComments": {"_total": 0}, "updateContent": {"person": {"apiStandardProfileRequest": {"headers": {"_total": 1, "values": [{"name": "x-li-auth-token", "value": "name:-LNy"}]}, "url": "http://api.linkedin.com/v1/people/Pa0L6dU"}, "currentStatus": "I'm looking forward to the Udacity Global meetup event here in Portland: http://lnkd.in/dh5MQz\nGreat way to support the next big thing in c…", "firstName": "Hobson", "headline": "Developer at Building Energy, Inc", "id": "ma0LLid", "lastName": "L", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_mVxsC0BnN52aqc24yWvoyA5haqc2ZwLCgzLv0EiBGp7n2jTwX-ls_dzgkSVIZu0", "siteStandardProfileRequest": {"url": "https://www.linkedin.com/profile/view?id=7375&authType=name&authToken=-LNy&trk=api*a26127*s26893*"}}}, "updateKey": "UNIU-73705-576270369559388-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "numLikes": 0, "timestamp": 1373900948273, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-1060864-576255824267264-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "numLikes": 0, "timestamp": 1373899922654, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-106088-5769411789496-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "likes": {"_total": 3, "values": [{"person": {"firstName": "J. P.", "headline": "Software Technologist", "id": "GUUXiHdd40", "lastName": "N", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_3Y7iZs_hWCTi0d-ZxAJn8XpT_pU-ZxGoW6GSZxT90nXZSnShgSPB4rdoNQi"}}, {"person": {"firstName": "Tyler", "headline": "Senior Associate at Toffler Associates", "id": "RCYVRJ", "lastName": "S", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_i7W-Lvgud5pur7I-eBpecy7IlZe0yFr_zWdOUMNHHuhiLkRWj8ufDpQBz"}}, {"person": {"firstName": "PaweÅ‚", "headline": "Senior programmer at EMG Systems", "id": "FjbEJi", "lastName": "S", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_SEgTdwePXDoInf9XIm-XfHanGqqoLk3uoLT0aMWtuW4ch4ekYCqF"}}]}, "numLikes": 3, "timestamp": 1373638523241, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-106764-5761479649536-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "likes": {"_total": 2, "values": [{"person": {"firstName": "João", "headline": "System Engineer", "id": "DXmxRB", "lastName": "M", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_Vu55rTWdTtWwT-xSwZiP4alVriEVYAwgNpq1vWY_xR7bU0kZ8mjE9"}}, {"person": {"firstName": "private", "id": "private", "lastName": "private"}}]}, "numLikes": 2, "timestamp": 1373638247645, "updateComments": {"_total": 1, "values": [{"comment": "Forgot to mention: Floor Drees wrote the article and was the workshop coach", "id": 155125, "person": {"firstName": "private", "id": "private", "lastName": "private"}, "sequenceNumber": 0, "timestamp": 1373638357000}]}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-10664-57614646232064-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "likes": {"_total": 2, "values": [{"person": {"firstName": "Tomáš", "headline": "Invisible software writer, amateur storyteller and wannabe clown.", "id": "37f-Kc", "lastName": "J", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_C9Sz75iM9tWx_54nX12tErnWLYY3-joGjcY6V85pwujCGB7"}}, {"person": {"firstName": "Miguel", "headline": "Senior Consultant at Red Hat", "id": "RsdH", "lastName": "P", "pictureUrl": "http://m.c.lnkd.licdn.com/mpr/mprx/0_9-9rsGcir_ciT05XHD8i2Asq4AM_oi4k1ly6SD"}}]}, "numLikes": 2, "timestamp": 1373530350025, "updateComments": {"_total": 1, "values": [{"comment": "Interesting read.....", "id": 1369, "person": {"firstName": "private", "id": "private", "lastName": "private"}, "sequenceNumber": 0, "timestamp": 1373533027000}]}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-1064-51227071488-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "numLikes": 0, "timestamp": 1373465844294, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-10664-33284581519360-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "numLikes": 0, "timestamp": 1373358138541, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-106088-3910880256-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "numLikes": 0, "timestamp": 1373354287104, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-10607-13277696-SHARE", "updateType": "STAT"}, {"isCommentable": true, "isLikable": true, "isLiked": false, "numLikes": 0, "timestamp": 1373273818071, "updateComments": {"_total": 0}, "updateContent": {"person": {"firstName": "private", "id": "private", "lastName": "private"}}, "updateKey": "UNIU-1060884-868226281472-SHARE", "updateType": "STAT"}]} friends-0.2.0+14.04.20140217.1/friends/tests/data/facebook-login.dat0000644000015201777760000000004412300444435024752 0ustar pbusernogroup00000000000000{"id": "801", "name": "Bart Person"}friends-0.2.0+14.04.20140217.1/friends/tests/data/linkedin_contacts.json0000644000015201777760000000604112300444435025772 0ustar pbusernogroup00000000000000{"_total": 4, "values": [{"apiStandardProfileRequest": {"headers": {"_total": 1, "values": [{"name": "x-li-auth-token", "value": "name:"}]}, "url": "http://api.linkedin.com"}, "firstName": "H", "headline": "Unix Administrator at NVIDIA", "id": "IFDI", "industry": "Computer Network Security", "lastName": "A", "location": {"country": {"code": "in"}, "name": "Pune Area, India"}, "pictureUrl": "http://m.c.lnkd.licdn.com", "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}}, {"apiStandardProfileRequest": {"headers": {"_total": 1, "values": [{"name": "x-li-auth-token", "value": "name:"}]}, "url": "http://api.linkedin.com"}, "firstName": "C", "headline": "Recent Graduate, Simon Fraser University", "id": "AefF", "industry": "Food Production", "lastName": "A", "location": {"country": {"code": "ca"}, "name": "Vancouver, Canada Area"}, "pictureUrl": "http://m.c.lnkd.licdn.com", "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}}, {"apiStandardProfileRequest": {"headers": {"_total": 1, "values": [{"name": "x-li-auth-token", "value": "name:"}]}, "url": "http://api.linkedin.com"}, "firstName": "R", "headline": "Technical Lead at Canonical Ltd.", "id": "DFdV", "industry": "Computer Software", "lastName": "A", "location": {"country": {"code": "nz"}, "name": "Auckland, New Zealand"}, "pictureUrl": "http://m.c.lnkd.licdn.com", "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}}, {"apiStandardProfileRequest": {"headers": {"_total": 1, "values": [{"name": "x-li-auth-token", "value": "name:"}]}, "url": "http://api.linkedin.com"}, "firstName": "A", "headline": "Sales manager at McBain Camera", "id": "xkBU", "industry": "Photography", "lastName": "Z", "location": {"country": {"code": "ca"}, "name": "Edmonton, Canada Area"}, "pictureUrl": "http://m.c.lnkd.licdn.com", "siteStandardProfileRequest": {"url": "https://www.linkedin.com"}}]} friends-0.2.0+14.04.20140217.1/friends/tests/data/twitter-multiple-links.dat0000644000015201777760000001054712300444435026555 0ustar pbusernogroup00000000000000{ "created_at": "Thu Jan 23 11:40:35 +0000 2014", "id": 426318539796930560, "id_str": "426318539796930560", "text": "An old people's home has recreated famous movie scenes for a wonderful calendar http://t.co/jjqteYzur0 http://t.co/JxQTPG7WLL", "source": "TweetDeck", "truncated": false, "in_reply_to_status_id": null, "in_reply_to_status_id_str": null, "in_reply_to_user_id": null, "in_reply_to_user_id_str": null, "in_reply_to_screen_name": null, "user": { "id": 16973333, "id_str": "16973333", "name": "The Independent", "screen_name": "Independent", "location": "London, United Kingdom", "description": "News, comment and features from The Independent. Also follow: @IndyVoices, \r\n@IndyPolitics, @IndyWorld and our journalists at http://t.co/YjS7NcXK4A", "url": "http://t.co/wDS5ly0QoO", "entities": { "url": { "urls": [ { "url": "http://t.co/wDS5ly0QoO", "expanded_url": "http://www.independent.co.uk", "display_url": "independent.co.uk", "indices": [ 0, 22 ] } ] }, "description": { "urls": [ { "url": "http://t.co/YjS7NcXK4A", "expanded_url": "http://ind.pn/Wdlm9a", "display_url": "ind.pn/Wdlm9a", "indices": [ 126, 148 ] } ] } }, "protected": false, "followers_count": 505759, "friends_count": 1747, "listed_count": 7890, "created_at": "Sun Oct 26 00:00:29 +0000 2008", "favourites_count": 29, "utc_offset": 0, "time_zone": "London", "geo_enabled": false, "verified": true, "statuses_count": 58003, "lang": "en", "contributors_enabled": false, "is_translator": false, "profile_background_color": "EBEBEB", "profile_background_image_url": "http://a0.twimg.com/profile_background_images/378800000119288704/4ac964c83462c88837dc1e735aa1a45e.png", "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/378800000119288704/4ac964c83462c88837dc1e735aa1a45e.png", "profile_background_tile": true, "profile_image_url": "http://pbs.twimg.com/profile_images/378800000706113664/d1a957578723e496c025be1e2577d06d_normal.jpeg", "profile_image_url_https": "https://pbs.twimg.com/profile_images/378800000706113664/d1a957578723e496c025be1e2577d06d_normal.jpeg", "profile_link_color": "FC051A", "profile_sidebar_border_color": "FFFFFF", "profile_sidebar_fill_color": "FFFFFF", "profile_text_color": "333333", "profile_use_background_image": true, "default_profile": false, "default_profile_image": false, "following": false, "follow_request_sent": false, "notifications": false }, "geo": null, "coordinates": null, "place": null, "contributors": null, "retweet_count": 131, "favorite_count": 83, "entities": { "hashtags": [], "symbols": [], "urls": [ { "url": "http://t.co/jjqteYzur0", "expanded_url": "http://ind.pn/1g3wX9q", "display_url": "ind.pn/1g3wX9q", "indices": [ 80, 102 ] } ], "user_mentions": [], "media": [ { "id": 426318539692056600, "id_str": "426318539692056576", "indices": [ 103, 125 ], "media_url": "http://pbs.twimg.com/media/BeqWc_-CIAAhmdc.jpg", "media_url_https": "https://pbs.twimg.com/media/BeqWc_-CIAAhmdc.jpg", "url": "http://t.co/JxQTPG7WLL", "display_url": "pic.twitter.com/JxQTPG7WLL", "expanded_url": "http://twitter.com/Independent/status/426318539796930560/photo/1", "type": "photo", "sizes": { "thumb": { "w": 150, "h": 150, "resize": "crop" }, "large": { "w": 1024, "h": 682, "resize": "fit" }, "small": { "w": 340, "h": 226, "resize": "fit" }, "medium": { "w": 600, "h": 400, "resize": "fit" } } } ] }, "favorited": false, "retweeted": false, "possibly_sensitive": false, "lang": "en" } friends-0.2.0+14.04.20140217.1/friends/tests/data/json-utf-8.dat0000644000015201777760000000002712300444435024006 0ustar pbusernogroup00000000000000{"yes": "\u00d1\u00d8"}friends-0.2.0+14.04.20140217.1/friends/tests/data/flickr-nophotos.dat0000644000015201777760000000003212300444435025211 0ustar pbusernogroup00000000000000{"photos": {"photo": []}} friends-0.2.0+14.04.20140217.1/friends/tests/data/instagram-login.dat0000644000015201777760000000052412300444435025171 0ustar pbusernogroup00000000000000{ "meta": { "code": 200 }, "data": { "username": "bpersons", "bio": "", "website": "", "profile_picture": "http://images.ak.instagram.com/profiles/anonymousUser.jpg", "full_name": "Bart Person", "counts": { "media": 174, "followed_by": 100, "follows": 503 }, "id": "801" } } friends-0.2.0+14.04.20140217.1/friends/tests/data/facebook_ids_not.json0000644000015201777760000000001712300444435025564 0ustar pbusernogroup00000000000000}invalid json{ friends-0.2.0+14.04.20140217.1/friends/tests/data/twitter-retweet.dat0000644000015201777760000001047112300444435025257 0ustar pbusernogroup00000000000000{"place":null,"user":{"id":836242932,"profile_background_tile":false,"geo_enabled":false,"profile_sidebar_fill_color":"DDEEF6","utc_offset":null,"entities":{"description":{"urls":[]}},"name":"Robert Bruce","is_translator":false,"default_profile_image":false,"profile_background_color":"C0DEED","favourites_count":9,"statuses_count":96,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/2631306428\/2a509db8a05b4310394b832d34a137a4_normal.png","protected":false,"created_at":"Thu Sep 20 19:53:35 +0000 2012","lang":"en","profile_background_image_url":"http:\/\/a0.twimg.com\/images\/themes\/theme1\/bg.png","profile_link_color":"0084B4","friends_count":44,"follow_request_sent":false,"profile_use_background_image":true,"default_profile":true,"profile_text_color":"333333","screen_name":"therealrobru","contributors_enabled":false,"url":null,"verified":false,"profile_background_image_url_https":"https:\/\/si0.twimg.com\/images\/themes\/theme1\/bg.png","time_zone":null,"followers_count":8,"following":false,"id_str":"836242932","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/2631306428\/2a509db8a05b4310394b832d34a137a4_normal.png","location":null,"profile_sidebar_border_color":"C0DEED","description":"Misanthrope. No pants. Tied to a tree with a skipping rope.","listed_count":0,"notifications":false},"favorited":false,"contributors":null,"in_reply_to_user_id":null,"id_str":"324220250889543682","retweet_count":1,"truncated":true,"created_at":"Tue Apr 16 17:58:26 +0000 2013","geo":null,"in_reply_to_status_id_str":null,"retweeted":true,"coordinates":null,"text":"RT @tarek_ziade: Just found a \"Notification of Inspection\" card in the bottom of my bag. looks like they were curious about those raspbe ...","in_reply_to_user_id_str":null,"retweeted_status":{"place":null,"user":{"id":15821652,"profile_background_tile":true,"geo_enabled":true,"profile_sidebar_fill_color":"DAECF4","utc_offset":3600,"entities":{"url":{"urls":[{"indices":[0,16],"url":"http:\/\/ziade.org","display_url":null,"expanded_url":null}]},"description":{"urls":[]}},"name":"Tarek Ziad\u00e9","is_translator":false,"default_profile_image":false,"profile_background_color":"C6E2EE","favourites_count":175,"statuses_count":8673,"profile_image_url":"http:\/\/a0.twimg.com\/profile_images\/3308520017\/f35acb40d385f320eea8a789c2a5a37b_normal.jpeg","protected":false,"created_at":"Tue Aug 12 12:40:35 +0000 2008","lang":"en","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/800421101\/30f37bcf2208ea3700fe07ce22722667.jpeg","profile_link_color":"1F98C7","friends_count":93,"follow_request_sent":false,"profile_use_background_image":true,"default_profile":false,"profile_text_color":"663B12","screen_name":"tarek_ziade","contributors_enabled":false,"url":"http:\/\/ziade.org","verified":false,"profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/800421101\/30f37bcf2208ea3700fe07ce22722667.jpeg","time_zone":"Paris","followers_count":3032,"following":true,"id_str":"15821652","profile_image_url_https":"https:\/\/si0.twimg.com\/profile_images\/3308520017\/f35acb40d385f320eea8a789c2a5a37b_normal.jpeg","location":"Turcey, France","profile_sidebar_border_color":"FFFFFF","description":"Country-side Python developer \u00b7 Works at Mozilla \u00b7 wrote some books","listed_count":279,"notifications":false},"favorited":false,"contributors":null,"in_reply_to_user_id":null,"id_str":"324200722273009666","retweet_count":1,"truncated":false,"created_at":"Tue Apr 16 16:40:50 +0000 2013","geo":null,"in_reply_to_status_id_str":null,"retweeted":true,"coordinates":null,"text":"Just found a \"Notification of Inspection\" card in the bottom of my bag. looks like they were curious about those raspberry-pi :O","in_reply_to_user_id_str":null,"in_reply_to_status_id":null,"source":"\u003Ca href=\"http:\/\/www.tweetdeck.com\" rel=\"nofollow\"\u003ETweetDeck\u003C\/a\u003E","in_reply_to_screen_name":null,"id":324200722273009666,"entities":{"urls":[],"hashtags":[],"user_mentions":[]}},"in_reply_to_status_id":null,"source":"\u003Ca href=\"http:\/\/www.ubuntu.com\" rel=\"nofollow\"\u003EUbuntu Online Accounts\u003C\/a\u003E","in_reply_to_screen_name":null,"id":324220250889543682,"entities":{"urls":[],"hashtags":[],"user_mentions":[{"name":"Tarek Ziad\u00e9","id_str":"15821652","indices":[3,15],"screen_name":"tarek_ziade","id":15821652}]}} friends-0.2.0+14.04.20140217.1/friends/tests/data/short.dat0000644000015201777760000000001712300444435023232 0ustar pbusernogroup00000000000000http://sho.rt/ friends-0.2.0+14.04.20140217.1/friends/tests/data/json-utf-16le.dat0000644000015201777760000000005612300444435024410 0ustar pbusernogroup00000000000000{"yes": "\u00d1\u00d8"}friends-0.2.0+14.04.20140217.1/friends/tests/data/durlme.dat0000644000015201777760000000015512300444435023366 0ustar pbusernogroup00000000000000{ "longUrl":"http://www.yahoo.com/somepath", "status":"ok", "shortUrl":"http://durl.me/5o", "key":"5o" } friends-0.2.0+14.04.20140217.1/friends/tests/data/flickr-full.dat0000644000015201777760000000763012300444435024315 0ustar pbusernogroup00000000000000{ "photos": { "photo": [ { "id": "8552892154", "secret": "a", "server": "8378", "farm": 9, "owner": "47303164@N00", "username": "raise my voice", "title": "Chocolate chai #yegcoffee", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363117902", "ownername": "raise my voice", "iconserver": 93, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8552845358", "secret": "b", "server": "8085", "farm": 9, "owner": "47303164@N00", "username": "raise my voice", "title": "Torah ark #yegjew", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363116818", "ownername": "raise my voice", "iconserver": 93, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8552661200", "secret": "c", "server": "8522", "farm": 9, "owner": "60551783@N00", "username": "Reinhard.Pantke", "title": "Henningsvaer", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363112533", "ownername": "Reinhard.Pantke", "iconserver": 8, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8550946245", "secret": "d", "server": "8107", "farm": 9, "owner": "60551783@N00", "username": "Reinhard.Pantke", "title": "Summerfeeling on Lofoten", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363098878", "ownername": "Reinhard.Pantke", "iconserver": 8, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8550829193", "secret": "e", "server": "8246", "farm": 9, "owner": "27204141@N05", "username": "Nelson Webb", "title": "St. Michael - The Archangel", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363096450", "ownername": "Nelson Webb", "iconserver": "2047", "iconfarm": 3, "latitude": 53.833156, "longitude": -112.330784, "accuracy": 15, "context": 0, "place_id": "4Y55lnhZVrNO", "woeid": "8496", "geo_is_family": 0, "geo_is_friend": 0, "geo_is_contact": 0, "geo_is_public": 1 }, { "id": "8551930826", "secret": "f", "server": "8247", "farm": 9, "owner": "27204141@N05", "username": "Nelson Webb", "title": "Pine scented air freshener", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363096449", "ownername": "Nelson Webb", "iconserver": "2047", "iconfarm": 3, "latitude": 53.878136, "longitude": -112.335162, "accuracy": 15, "context": 0, "place_id": "4Y55lnhZVrNO", "woeid": "8496", "geo_is_family": 0, "geo_is_friend": 0, "geo_is_contact": 0, "geo_is_public": 1 }, { "id": "8549658873", "secret": "g", "server": "8239", "farm": 9, "owner": "30584843@N00", "username": "Mark Iocchelli", "title": "Sleepy Hollow", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363055714", "ownername": "Mark Iocchelli", "iconserver": 22, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8548811967", "secret": "h", "server": "8229", "farm": 9, "owner": "47303164@N00", "username": "raise my voice", "title": "Trying out The Wokkery #yegfood", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363028798", "ownername": "raise my voice", "iconserver": 93, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8548753789", "secret": "i", "server": "8512", "farm": 9, "owner": "30584843@N00", "username": "Mark Iocchelli", "title": "Alberta Rail Pipeline", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363027071", "ownername": "Mark Iocchelli", "iconserver": 22, "iconfarm": 1, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 }, { "id": "8549607582", "secret": "j", "server": "8087", "farm": 9, "owner": "60404254@N00", "username": "tavis_mcnally", "title": "26 weeks", "ispublic": 1, "isfriend": 0, "isfamily": 0, "dateupload": "1363022277", "ownername": "tavis_mcnally", "iconserver": "2182", "iconfarm": 3, "latitude": 0, "longitude": 0, "accuracy": 0, "context": 0 } ], "total": "290", "page": 1, "per_page": 10, "pages": 29 }, "stat": "ok" } friends-0.2.0+14.04.20140217.1/friends/tests/data/foursquare-full.dat0000644000015201777760000000316412300444435025235 0ustar pbusernogroup00000000000000{"meta":{"code":200},"notifications":[{"type":"notificationTray","item":{"unreadCount":0}}],"response":{"recent":[{"id":"50574c9ce4b0a9a6e84433a0","createdAt":1347898524,"type":"checkin","shout":"Working on friends's foursquare plugin.","timeZoneOffset":-300,"user":{"id":"37199983","firstName":"Jimbob","lastName":"Smith","relationship":"self","photo":{"prefix":"https:\/\/irs0.4sqi.net\/img\/user\/","suffix":"\/5IEW3VIX55BBEXAO.jpg"}},"venue":{"id":"4e73f722fa76059700582a27","name":"Pop Soda's Coffee House & Gallery","contact":{"phone":"2044157666","formattedPhone":"(204) 415-7666","twitter":"PopSodasWpg"},"location":{"address":"625 Portage Ave.","crossStreet":"Furby St.","lat":49.88873164336725,"lng":-97.158043384552,"postalCode":"R3B 2G4","city":"Winnipeg","state":"MB","country":"Canada","cc":"CA"},"categories":[{"id":"4bf58dd8d48988d16d941735","name":"Café","pluralName":"Cafés","shortName":"Café","icon":{"prefix":"https:\/\/foursquare.com\/img\/categories_v2\/food\/cafe_","suffix":".png"},"primary":true}],"verified":false,"stats":{"checkinsCount":216,"usersCount":84,"tipCount":8},"url":"http:\/\/www.popsodascoffeehouse.com","likes":{"count":0,"groups":[]},"friendVisits":{"count":0,"summary":"You've been here","items":[{"visitedCount":1,"liked":false,"user":{"id":"37199983","firstName":"Jimbob","lastName":"Smith","relationship":"self","photo":{"prefix":"https:\/\/irs0.4sqi.net\/img\/user\/","suffix":"\/5IEW3VIX55BBEXAO.jpg"}}}]},"beenHere":{"count":1,"marked":true},"specials":{"count":0}},"source":{"name":"foursquare for Android","url":"https:\/\/foursquare.com\/download\/#\/android"},"photos":{"count":0,"items":[]}}]}} friends-0.2.0+14.04.20140217.1/friends/tests/data/instagram-full.dat0000644000015201777760000004111612300444435025025 0ustar pbusernogroup00000000000000{ "pagination": { "next_url": "https://api.instagram.com/v1/users/self/feed?access_token=4abc&max_id=431429392441037546_46931811", "next_max_id": "431429392441037546_46931811" }, "meta": { "code": 200 }, "data": [ { "attribution": null, "tags": [], "type": "image", "location": null, "comments": { "count": 1, "data": [ { "created_time": "1365680633", "text": "Wtf is that from?", "from": { "username": "ellllliottttt", "profile_picture": "http://images.ak.instagram.com/profiles/profile_13811917_75sq_1322106636.jpg", "id": "13811917", "full_name": "Elliott Markowitz" }, "id": "431682899841631793" } ] }, "filter": "Normal", "created_time": "1365655801", "link": "http://instagram.com/p/X859raK8fx/", "likes": { "count": 8, "data": [ { "username": "noahvargass", "profile_picture": "http://images.ak.instagram.com/profiles/profile_1037856_75sq_1354178959.jpg", "id": "1037856", "full_name": "Noah Vargas" }, { "username": "deniboring", "profile_picture": "http://images.ak.instagram.com/profiles/profile_16828740_75sq_1345748703.jpg", "id": "16828740", "full_name": "deniboring" }, { "username": "gabriel_cordero", "profile_picture": "http://images.ak.instagram.com/profiles/profile_185127499_75sq_1340358021.jpg", "id": "185127499", "full_name": "cheeseburger pocket" }, { "username": "inmyheadache", "profile_picture": "http://images.ak.instagram.com/profiles/profile_22645548_75sq_1343529900.jpg", "id": "22645548", "full_name": "Tim F" }, { "username": "ellllliottttt", "profile_picture": "http://images.ak.instagram.com/profiles/profile_13811917_75sq_1322106636.jpg", "id": "13811917", "full_name": "Elliott Markowitz" }, { "username": "eyesofsatan", "profile_picture": "http://images.ak.instagram.com/profiles/profile_2526196_75sq_1352254863.jpg", "id": "2526196", "full_name": "Christopher Hansell" }, { "username": "lakeswimming", "profile_picture": "http://images.ak.instagram.com/profiles/profile_3610143_75sq_1363648525.jpg", "id": "3610143", "full_name": "lakeswimming" }, { "username": "jtesnakis", "profile_picture": "http://images.ak.instagram.com/profiles/profile_214679263_75sq_1346185198.jpg", "id": "214679263", "full_name": "Jonathan" } ] }, "images": { "low_resolution": { "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_6.jpg", "width": 306, "height": 306 }, "thumbnail": { "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_5.jpg", "width": 150, "height": 150 }, "standard_resolution": { "url": "http://distilleryimage9.s3.amazonaws.com/44ad8486a26311e2872722000a1fd26f_7.jpg", "width": 612, "height": 612 } }, "caption": null, "user_has_liked": false, "id": "431474591469914097_223207800", "user": { "username": "joshwolp", "website": "", "profile_picture": "http://images.ak.instagram.com/profiles/profile_223207800_75sq_1347753109.jpg", "full_name": "Josh", "bio": "", "id": "223207800" } }, { "attribution": null, "tags": [ "nofilter" ], "type": "image", "location": { "latitude": 40.702485554, "longitude": -73.929230548 }, "comments": { "count": 3, "data": [ { "created_time": "1365654315", "text": "I remember pushing that little guy of the swings a few times....", "from": { "username": "squidneylol", "profile_picture": "http://images.ak.instagram.com/profiles/profile_5917696_75sq_1336705905.jpg", "id": "5917696", "full_name": "Syd" }, "id": "431462132263145102" }, { "created_time": "1365665741", "text": "Stop it!!!", "from": { "username": "nightruiner", "profile_picture": "http://images.ak.instagram.com/profiles/profile_31239607_75sq_1358968326.jpg", "id": "31239607", "full_name": "meredith gaydosh" }, "id": "431557973837598336" }, { "created_time": "1365691283", "text": "You know I hate being held.", "from": { "username": "piratemaddi", "profile_picture": "http://images.ak.instagram.com/profiles/profile_191388805_75sq_1341897870.jpg", "id": "191388805", "full_name": "piratemaddi" }, "id": "431772240369143639" } ] }, "filter": "Normal", "created_time": "1365651440", "link": "http://instagram.com/p/X8xpYwk82w/", "likes": { "count": 17, "data": [ { "username": "meaghanlou", "profile_picture": "http://images.ak.instagram.com/profiles/profile_41007792_75sq_1334443633.jpg", "id": "41007792", "full_name": "meaghanlou" }, { "username": "shahwiththat", "profile_picture": "http://images.ak.instagram.com/profiles/profile_40353923_75sq_1361330343.jpg", "id": "40353923", "full_name": "Soraya Shah" }, { "username": "marsyred", "profile_picture": "http://images.ak.instagram.com/profiles/profile_216361031_75sq_1346524176.jpg", "id": "216361031", "full_name": "marsyred" }, { "username": "thewhitewizzard", "profile_picture": "http://images.ak.instagram.com/profiles/profile_189027880_75sq_1341183134.jpg", "id": "189027880", "full_name": "Drew Mack" }, { "username": "juddymagee", "profile_picture": "http://images.ak.instagram.com/profiles/profile_8993099_75sq_1315066258.jpg", "id": "8993099", "full_name": "juddymagee" }, { "username": "pixxamonster", "profile_picture": "http://images.ak.instagram.com/profiles/profile_1672624_75sq_1364673157.jpg", "id": "1672624", "full_name": "Sonia 👓 + Dita ðŸˆ" }, { "username": "tongue_in_cheek", "profile_picture": "http://images.ak.instagram.com/profiles/profile_5710964_75sq_1345359909.jpg", "id": "5710964", "full_name": "merrily." }, { "username": "nightruiner", "profile_picture": "http://images.ak.instagram.com/profiles/profile_31239607_75sq_1358968326.jpg", "id": "31239607", "full_name": "meredith gaydosh" }, { "username": "eliz_mortals", "profile_picture": "http://images.ak.instagram.com/profiles/profile_36417303_75sq_1364179217.jpg", "id": "36417303", "full_name": "eliz_mortals" }, { "username": "caranicoletti", "profile_picture": "http://images.ak.instagram.com/profiles/profile_14162814_75sq_1359848230.jpg", "id": "14162814", "full_name": "Cara Nicoletti" } ] }, "images": { "low_resolution": { "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_6.jpg", "width": 306, "height": 306 }, "thumbnail": { "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_5.jpg", "width": 150, "height": 150 }, "standard_resolution": { "url": "http://distilleryimage8.s3.amazonaws.com/1d99da5ca25911e2822f22000a9f09ca_7.jpg", "width": 612, "height": 612 } }, "caption": { "created_time": "1365651465", "text": "National siblings day @piratemaddi ? You mustn't've known. #nofilter", "from": { "username": "what_a_handsome_boy", "profile_picture": "http://images.ak.instagram.com/profiles/profile_5891266_75sq_1345460082.jpg", "id": "5891266", "full_name": "Fence!" }, "id": "431438224520630210" }, "user_has_liked": false, "id": "431438012683111856_5891266", "user": { "username": "what_a_handsome_boy", "website": "", "profile_picture": "http://images.ak.instagram.com/profiles/profile_5891266_75sq_1345460082.jpg", "full_name": "Fence!", "bio": "", "id": "5891266" } }, { "attribution": null, "tags": [ "canada", "iphone", "punklife", "acap", "doom", "crust" ], "type": "image", "location": null, "comments": { "count": 7, "data": [ { "created_time": "1365651434", "text": "I have not seen him for a minute, he is a great guy!", "from": { "username": "rocktoberblood", "profile_picture": "http://images.ak.instagram.com/profiles/profile_210506849_75sq_1355190976.jpg", "id": "210506849", "full_name": "Nate Phillips" }, "id": "431437959929388541" }, { "created_time": "1365652422", "text": "Saw him puke in the elevator at the Hilton when Doom played chaos.", "from": { "username": "occult_obsession", "profile_picture": "http://images.ak.instagram.com/profiles/profile_6080835_75sq_1364154159.jpg", "id": "6080835", "full_name": "Evan Vellela" }, "id": "431446251162427175" }, { "created_time": "1365652900", "text": "Lol ^ who DIDN'T do this at chaos", "from": { "username": "laurababbili", "profile_picture": "http://images.ak.instagram.com/profiles/profile_1238692_75sq_1361065720.jpg", "id": "1238692", "full_name": "Laura Babbili" }, "id": "431450261906907041" }, { "created_time": "1365655310", "text": "I tried to kiss the singer of cocksparrer in that elevator.", "from": { "username": "gregdaly", "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg", "id": "31133446", "full_name": "greg daly" }, "id": "431470473175752176" }, { "created_time": "1365655687", "text": "Leant that dude a 900 Marshall a time ago! Love that dude!!", "from": { "username": "nickatnightengale", "profile_picture": "http://images.ak.instagram.com/profiles/profile_29366372_75sq_1354241971.jpg", "id": "29366372", "full_name": "Nick Poulos" }, "id": "431473639539727956" }, { "created_time": "1365658654", "text": "Hahaha!!", "from": { "username": "jeremyhush", "profile_picture": "http://images.ak.instagram.com/profiles/profile_43750226_75sq_1335024423.jpg", "id": "43750226", "full_name": "Jeremy Hush" }, "id": "431498525729480083" }, { "created_time": "1365685226", "text": "There's an app for that", "from": { "username": "liam_wilson", "profile_picture": "http://images.ak.instagram.com/profiles/profile_4357248_75sq_1327009309.jpg", "id": "4357248", "full_name": "Liam Wilson" }, "id": "431721432141388885" } ] }, "filter": "X-Pro II", "created_time": "1365650801", "link": "http://instagram.com/p/X8wbTQtd3Y/", "likes": { "count": 36, "data": [ { "username": "fatbenatar", "profile_picture": "http://images.ak.instagram.com/profiles/profile_280136224_75sq_1357073366.jpg", "id": "280136224", "full_name": "China Oxendine" }, { "username": "tejleo", "profile_picture": "http://images.ak.instagram.com/profiles/profile_10403332_75sq_1354058520.jpg", "id": "10403332", "full_name": "tejleo" }, { "username": "tonyromeo13", "profile_picture": "http://images.ak.instagram.com/profiles/profile_255933637_75sq_1360272947.jpg", "id": "255933637", "full_name": "Tony Romeo" }, { "username": "libraryrat4", "profile_picture": "http://images.ak.instagram.com/profiles/profile_17715046_75sq_1325381597.jpg", "id": "17715046", "full_name": "libraryrat4" }, { "username": "boredwithapathy", "profile_picture": "http://images.ak.instagram.com/profiles/profile_174679368_75sq_1338500455.jpg", "id": "174679368", "full_name": "Lisa McCarthy" }, { "username": "justinpittney", "profile_picture": "http://images.ak.instagram.com/profiles/profile_10976940_75sq_1361306615.jpg", "id": "10976940", "full_name": "Justin Pittney" }, { "username": "nuclearhell", "profile_picture": "http://images.ak.instagram.com/profiles/profile_212702314_75sq_1361642319.jpg", "id": "212702314", "full_name": "Rachel Whittaker" }, { "username": "gregelk", "profile_picture": "http://images.ak.instagram.com/profiles/profile_240084204_75sq_1359223473.jpg", "id": "240084204", "full_name": "Greg Elk" }, { "username": "bradhasher", "profile_picture": "http://images.ak.instagram.com/profiles/profile_283278325_75sq_1357514041.jpg", "id": "283278325", "full_name": "Brett Ellingson" }, { "username": "zaimlmzim", "profile_picture": "http://images.ak.instagram.com/profiles/profile_180060277_75sq_1363118292.jpg", "id": "180060277", "full_name": "zaimlmzim" } ] }, "images": { "low_resolution": { "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_6.jpg", "width": 306, "height": 306 }, "thumbnail": { "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_5.jpg", "width": 150, "height": 150 }, "standard_resolution": { "url": "http://distilleryimage4.s3.amazonaws.com/a051f882a25711e282e122000a1f9aae_7.jpg", "width": 612, "height": 612 } }, "caption": { "created_time": "1365650915", "text": "This is my friend Scoot. He plays bass in #doom and is a mega rock star in newfoundland. Here you can see him signing an autograph on an iphone. #crust #punklife #iphone #canada #acap", "from": { "username": "gregdaly", "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg", "id": "31133446", "full_name": "greg daly" }, "id": "431433605218426202" }, "user_has_liked": false, "id": "431432646660578776_31133446", "user": { "username": "gregdaly", "website": "", "profile_picture": "http://images.ak.instagram.com/profiles/profile_31133446_75sq_1333488205.jpg", "full_name": "greg daly", "bio": "", "id": "31133446" } } ] } friends-0.2.0+14.04.20140217.1/friends/tests/data/json-utf-32be.dat0000644000015201777760000000013412300444435024371 0ustar pbusernogroup00000000000000{"yes": "\u00d1\u00d8"}friends-0.2.0+14.04.20140217.1/friends/tests/data/facebook_ids_corrupt.json0000644000015201777760000000011012300444435026454 0ustar pbusernogroup00000000000000BÖÀæ:@•7g¥6Ãw3ÞÐb‚ïÍb#±Ûî3#d²;æ_ôþí§K9ÌEÊÞþsL¥P-8çuôÆýÚéñƒ{†­¶friends-0.2.0+14.04.20140217.1/friends/tests/data/json-utf-32le.dat0000644000015201777760000000013412300444435024403 0ustar pbusernogroup00000000000000{"yes": "\u00d1\u00d8"}friends-0.2.0+14.04.20140217.1/friends/tests/data/twitter-send.dat0000644000015201777760000000570312300444435024533 0ustar pbusernogroup00000000000000 { "coordinates": null, "truncated": false, "created_at": "Tue Aug 28 21:16:23 +0000 2012", "favorited": false, "id_str": "240558470661799936", "in_reply_to_user_id_str": null, "entities": { "urls": [ ], "hashtags": [ ], "user_mentions": [ ] }, "text": "just another test", "contributors": null, "id": 240558470661799936, "retweet_count": 0, "in_reply_to_status_id_str": null, "geo": null, "retweeted": false, "in_reply_to_user_id": null, "place": null, "source": "OAuth Dancer Reborn", "user": { "name": "OAuth Dancer", "profile_sidebar_fill_color": "DDEEF6", "profile_background_tile": true, "profile_sidebar_border_color": "C0DEED", "profile_image_url": "http://a0.twimg.com/profile_images/730275945/oauth-dancer_normal.jpg", "created_at": "Wed Mar 03 19:37:35 +0000 2010", "location": "San Francisco, CA", "follow_request_sent": false, "id_str": "119476949", "is_translator": false, "profile_link_color": "0084B4", "entities": { "url": { "urls": [ { "expanded_url": null, "url": "http://bit.ly/oauth-dancer", "indices": [ 0, 26 ], "display_url": null } ] }, "description": null }, "default_profile": false, "url": "http://bit.ly/oauth-dancer", "contributors_enabled": false, "favourites_count": 7, "utc_offset": null, "profile_image_url_https": "https://si0.twimg.com/profile_images/730275945/oauth-dancer_normal.jpg", "id": 119476949, "listed_count": 1, "profile_use_background_image": true, "profile_text_color": "333333", "followers_count": 28, "lang": "en", "protected": false, "geo_enabled": true, "notifications": false, "description": "", "profile_background_color": "C0DEED", "verified": false, "time_zone": null, "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/80151733/oauth-dance.png", "statuses_count": 166, "profile_background_image_url": "http://a0.twimg.com/profile_background_images/80151733/oauth-dance.png", "default_profile_image": false, "friends_count": 14, "following": false, "show_all_inline_media": false, "screen_name": "oauth_dancer" }, "in_reply_to_screen_name": null, "in_reply_to_status_id": null } friends-0.2.0+14.04.20140217.1/friends/tests/data/flickr-xml.dat0000644000015201777760000000013512300444435024144 0ustar pbusernogroup00000000000000 8488552823 friends-0.2.0+14.04.20140217.1/friends/tests/data/twitter-home.dat0000644000015201777760000002722512300444435024535 0ustar pbusernogroup00000000000000 [ { "coordinates": null, "truncated": false, "created_at": "Tue Aug 28 21:16:23 +0000 2012", "favorited": false, "id_str": "240558470661799936", "in_reply_to_user_id_str": null, "entities": { "urls": [ ], "hashtags": [ ], "user_mentions": [ ] }, "text": "just another test", "contributors": null, "id": 240558470661799936, "retweet_count": 0, "in_reply_to_status_id_str": null, "geo": null, "retweeted": false, "in_reply_to_user_id": null, "place": null, "source": "OAuth Dancer Reborn", "user": { "name": "OAuth Dancer", "profile_sidebar_fill_color": "DDEEF6", "profile_background_tile": true, "profile_sidebar_border_color": "C0DEED", "profile_image_url": "http://a0.twimg.com/profile_images/730275945/oauth-dancer_normal.jpg", "created_at": "Wed Mar 03 19:37:35 +0000 2010", "location": "San Francisco, CA", "follow_request_sent": false, "id_str": "119476949", "is_translator": false, "profile_link_color": "0084B4", "entities": { "url": { "urls": [ { "expanded_url": null, "url": "http://bit.ly/oauth-dancer", "indices": [ 0, 26 ], "display_url": null } ] }, "description": null }, "default_profile": false, "url": "http://bit.ly/oauth-dancer", "contributors_enabled": false, "favourites_count": 7, "utc_offset": null, "profile_image_url_https": "https://si0.twimg.com/profile_images/730275945/oauth-dancer_normal.jpg", "id": 119476949, "listed_count": 1, "profile_use_background_image": true, "profile_text_color": "333333", "followers_count": 28, "lang": "en", "protected": false, "geo_enabled": true, "notifications": false, "description": "", "profile_background_color": "C0DEED", "verified": false, "time_zone": null, "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/80151733/oauth-dance.png", "statuses_count": 166, "profile_background_image_url": "http://a0.twimg.com/profile_background_images/80151733/oauth-dance.png", "default_profile_image": false, "friends_count": 14, "following": false, "show_all_inline_media": false, "screen_name": "oauth_dancer" }, "in_reply_to_screen_name": null, "in_reply_to_status_id": null }, { "coordinates": { "coordinates": [ -122.25831, 37.871609 ], "type": "Point" }, "truncated": false, "created_at": "Tue Aug 28 21:08:15 +0000 2012", "favorited": false, "id_str": "240556426106372096", "in_reply_to_user_id_str": null, "entities": { "media":[{"type":"photo", "sizes":{ "thumb":{"h":150, "resize":"crop", "w":150}, "large":{"h":238, "resize":"fit", "w":226}, "medium":{"h":238, "resize":"fit", "w":226}, "small":{"h":238, "resize":"fit", "w":226}}, "indices":[78,98], "url":"http://t.co/rJC5Pxsu", "media_url":"http://p.twimg.com/AZVLmp-CIAAbkyy.jpg", "display_url":"pic.twitter.com\/rJC5Pxsu", "id":114080493040967680, "id_str":"114080493040967680", "expanded_url": "http://twitter.com/yunorno/status/114080493036773378/photo/1", "media_url_https":"https://p.twimg.com/AZVLmp-CIAAbkyy.jpg"}], "hashtags": [ ], "user_mentions": [ { "name": "Cal", "id_str": "17445752", "id": 17445752, "indices": [ 60, 64 ], "screen_name": "Cal" }, { "name": "Othman Laraki", "id_str": "20495814", "id": 20495814, "indices": [ 70, 77 ], "screen_name": "othman" } ] }, "text": "lecturing at the \"analyzing big data with twitter\" class at @cal with @othman http://t.co/rJC5Pxsu", "contributors": null, "id": 240556426106372096, "retweet_count": 3, "in_reply_to_status_id_str": null, "geo": { "coordinates": [ 37.871609, -122.25831 ], "type": "Point" }, "retweeted": false, "possibly_sensitive": false, "in_reply_to_user_id": null, "place": { "name": "Berkeley", "country_code": "US", "country": "United States", "attributes": { }, "url": "http://api.twitter.com/1/geo/id/5ef5b7f391e30aff.json", "id": "5ef5b7f391e30aff", "bounding_box": { "coordinates": [ [ [ -122.367781, 37.835727 ], [ -122.234185, 37.835727 ], [ -122.234185, 37.905824 ], [ -122.367781, 37.905824 ] ] ], "type": "Polygon" }, "full_name": "Berkeley, CA", "place_type": "city" }, "source": "Safari on iOS", "user": { "name": "Raffi Krikorian", "profile_sidebar_fill_color": "DDEEF6", "profile_background_tile": false, "profile_sidebar_border_color": "C0DEED", "profile_image_url": "http://a0.twimg.com/profile_images/1270234259/raffi-headshot-casual_normal.png", "created_at": "Sun Aug 19 14:24:06 +0000 2007", "location": "San Francisco, California", "follow_request_sent": false, "id_str": "8285392", "is_translator": false, "profile_link_color": "0084B4", "entities": { "url": { "urls": [ { "expanded_url": "http://about.me/raffi.krikorian", "url": "http://t.co/eNmnM6q", "indices": [ 0, 19 ], "display_url": "about.me/raffi.krikorian" } ] }, "description": { "urls": [ ] } }, "default_profile": true, "url": "http://t.co/eNmnM6q", "contributors_enabled": false, "favourites_count": 724, "utc_offset": -28800, "profile_image_url_https": "https://si0.twimg.com/profile_images/1270234259/raffi-headshot-casual_normal.png", "id": 8285392, "listed_count": 619, "profile_use_background_image": true, "profile_text_color": "333333", "followers_count": 18752, "lang": "en", "protected": false, "geo_enabled": true, "notifications": false, "description": "Director of @twittereng's Platform Services. I break things.", "profile_background_color": "C0DEED", "verified": false, "time_zone": "Pacific Time (US & Canada)", "profile_background_image_url_https": "https://si0.twimg.com/images/themes/theme1/bg.png", "statuses_count": 5007, "profile_background_image_url": "http://a0.twimg.com/images/themes/theme1/bg.png", "default_profile_image": false, "friends_count": 701, "following": true, "show_all_inline_media": true, "screen_name": "raffi" }, "in_reply_to_screen_name": null, "in_reply_to_status_id": null }, { "coordinates": null, "truncated": false, "created_at": "Tue Aug 28 19:59:34 +0000 2012", "favorited": false, "id_str": "240539141056638977", "in_reply_to_user_id_str": null, "entities": { "urls": [ ], "hashtags": [ ], "user_mentions": [ ] }, "text": "You'd be right more often if you thought you were wrong.", "contributors": null, "id": 240539141056638977, "retweet_count": 1, "in_reply_to_status_id_str": null, "geo": null, "retweeted": false, "in_reply_to_user_id": null, "place": null, "source": "web", "user": { "name": "Taylor Singletary", "profile_sidebar_fill_color": "FBFBFB", "profile_background_tile": true, "profile_sidebar_border_color": "000000", "profile_image_url": "http://a0.twimg.com/profile_images/2546730059/f6a8zq58mg1hn0ha8vie_normal.jpeg", "created_at": "Wed Mar 07 22:23:19 +0000 2007", "location": "San Francisco, CA", "follow_request_sent": false, "id_str": "819797", "is_translator": false, "profile_link_color": "c71818", "entities": { "url": { "urls": [ { "expanded_url": "http://www.rebelmouse.com/episod/", "url": "http://t.co/Lxw7upbN", "indices": [ 0, 20 ], "display_url": "rebelmouse.com/episod/" } ] }, "description": { "urls": [ ] } }, "default_profile": false, "url": "http://t.co/Lxw7upbN", "contributors_enabled": false, "favourites_count": 15990, "utc_offset": -28800, "profile_image_url_https": "https://si0.twimg.com/profile_images/2546730059/f6a8zq58mg1hn0ha8vie_normal.jpeg", "id": 819797, "listed_count": 340, "profile_use_background_image": true, "profile_text_color": "D20909", "followers_count": 7126, "lang": "en", "protected": false, "geo_enabled": true, "notifications": false, "description": "Reality Technician, Twitter API team, synthesizer enthusiast; a most excellent adventure in timelines. I know it's hard to believe in something you can't see.", "profile_background_color": "000000", "verified": false, "time_zone": "Pacific Time (US & Canada)", "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/643655842/hzfv12wini4q60zzrthg.png", "statuses_count": 18076, "profile_background_image_url": "http://a0.twimg.com/profile_background_images/643655842/hzfv12wini4q60zzrthg.png", "default_profile_image": false, "friends_count": 5444, "following": true, "show_all_inline_media": true, "screen_name": "episod" }, "in_reply_to_screen_name": null, "in_reply_to_status_id": null } ] friends-0.2.0+14.04.20140217.1/friends/tests/data/facebook-full.dat0000644000015201777760000002407412300444435024615 0ustar pbusernogroup00000000000000{ "data": [ { "id": "userid_postid1", "from": { "name": "Yours Truly", "id": "56789" }, "message": "Writing code that supports geotagging data from facebook. If y'all could make some geotagged facebook posts for me to test with, that'd be super.", "actions": [ { "name": "Comment", "link": "https://www.facebook.com/userid/posts/postid1" }, { "name": "Like", "link": "https://www.facebook.com/userid/posts/postid1" } ], "privacy": { "value": "" }, "place": { "id": "103135879727382", "name": "Victoria, British Columbia", "location": { "street": "", "zip": "", "latitude": 48.4333, "longitude": -123.35 } }, "type": "status", "status_type": "mobile_status_update", "created_time": "2013-03-12T21:27:56+0000", "updated_time": "2013-03-13T23:29:07+0000", "likes": { "data": [ { "name": "Anna", "id": "12345" } ], "count": 1 }, "comments": { "data": [ { "id": "postid1_commentid1", "from": { "name": "Grandma", "id": "9876" }, "message": "If I knew what a geotagged facebook post was I might be able to comply!", "created_time": "2013-03-12T22:56:17+0000" }, { "id": "postid1_commentid2", "from": { "name": "Father", "id": "234" }, "message": "don't know how", "created_time": "2013-03-12T23:29:45+0000" }, { "id": "postid1_commentid3", "from": { "name": "Mother", "id": "456" }, "message": "HUH!!!!", "created_time": "2013-03-13T02:20:27+0000" }, { "id": "postid1_commentid4", "from": { "name": "Yours Truly", "id": "56789" }, "message": "Coming up with tons of fake data is hard!", "created_time": "2013-03-13T23:29:07+0000" } ], "count": 4 } }, { "id": "270843027745_10151370303782746", "from": { "category": "Shopping/retail", "category_list": [ { "id": "128003127270269", "name": "Bike Shop" } ], "name": "Western Cycle Source for Sports", "id": "270843027745" }, "story": "Western Cycle Source for Sports updated their cover photo.", "story_tags": { "0": [ { "id": "270843027745", "name": "Western Cycle Source for Sports", "offset": 0, "length": 31, "type": "page" } ] }, "picture": "https://fbcdn-photos-a.akamaihd.net/hphotos-ak-snc7/482418_10151370303672746_1924798223_s.jpg", "link": "https://www.facebook.com/photo.php?fbid=10151370303672746&set=a.10150598301902746.381693.270843027745&type=1&relevant_count=1", "icon": "https://fbstatic-a.akamaihd.net/rsrc.php/v2/yz/r/StEh3RhPvjk.gif", "actions": [ { "name": "Comment", "link": "https://www.facebook.com/270843027745/posts/10151370303782746" }, { "name": "Like", "link": "https://www.facebook.com/270843027745/posts/10151370303782746" } ], "privacy": { "value": "" }, "place": { "id": "270843027745", "name": "Western Cycle Source for Sports", "location": { "street": "1550 8th Ave", "city": "Regina", "state": "SK", "country": "Canada", "zip": "S4R 1E4", "latitude": 50.45679, "longitude": -104.60276 } }, "type": "photo", "object_id": "10151370303672746", "created_time": "2013-03-11T23:46:06+0000", "updated_time": "2013-03-11T23:46:06+0000", "likes": { "data": [ { "name": "Lou Schwindt", "id": "57" }, { "name": "Maureen Daniel", "id": "72" }, { "name": "Lee Watson", "id": "696" }, { "name": "Rob Nelson", "id": "40" } ], "count": 10 }, "comments": { "count": 0 } }, { "id": "161247843901324_629147610444676", "from": { "category": "Hotel", "category_list": [ { "id": "164243073639257", "name": "Hotel" } ], "name": "Best Western Denver Southwest", "id": "161247843901324" }, "message": "Today only -- Come meet Caroline and Meredith and Stanley the Stegosaurus (& Greg & Joe, too!) at the TechZulu Trend Lounge, Hilton Garden Inn 18th floor, 500 N Interstate 35, Austin, Texas. Monday, March 11th, 4:00pm to 7:00 pm. Also here Hannah Hart (My Drunk Kitchen) and Angry Video Game Nerd producer, Sean Keegan. Stanley is in the lobby.", "picture": "https://fbcdn-photos-a.akamaihd.net/hphotos-ak-snc7/601266_629147587111345_968504279_s.jpg", "link": "https://www.facebook.com/photo.php?fbid=629147587111345&set=a.173256162700492.47377.161247843901324&type=1&relevant_count=1", "icon": "https://fbstatic-a.akamaihd.net/rsrc.php/v2/yz/r/StEh3RhPvjk.gif", "actions": [ { "name": "Comment", "link": "https://www.facebook.com/161247843901324/posts/629147610444676" }, { "name": "Like", "link": "https://www.facebook.com/161247843901324/posts/629147610444676" } ], "privacy": { "value": "" }, "place": { "id": "132709090079327", "name": "Hilton Garden Inn Austin Downtown/Convention Center", "location": { "street": "500 North Interstate 35", "city": "Austin", "state": "TX", "country": "United States", "zip": "78701", "latitude": 30.265384957204, "longitude": -97.735604602521 } }, "type": "photo", "status_type": "added_photos", "object_id": "629147587111345", "created_time": "2013-03-11T20:49:10+0000", "updated_time": "2013-03-11T23:51:25+0000", "likes": { "data": [ { "name": "Andrew Henninger", "id": "11" }, { "name": "Sarah Brents", "id": "22" }, { "name": "Thomas Bush", "id": "33" }, { "name": "Jennifer Tornetta", "id": "44" } ], "count": 84 }, "comments": { "data": [ { "id": "1612484301324_6294760444676_1126376", "from": { "name": "Amy Gibbs", "id": "55" }, "message": "You have to love a family that travels with their stegasaurus.", "created_time": "2013-03-11T23:51:05+0000", "likes": 2 }, { "id": "1612843901324_6294761044676_1124378", "from": { "name": "Amy Gibbs", "id": "55" }, "message": "*stegosaurus...sorry!", "created_time": "2013-03-11T23:51:25+0000", "likes": 1 } ], "count": 11 } }, { "id": "104443_100085049977", "from": { "name": "Guy Frenchie", "id": "1244414" }, "story": "Guy Frenchie did some things with some stuff.", "story_tags": { "0": [ { "id": "1244414", "name": "Guy Frenchie", "offset": 0, "length": 16, "type": "user" } ], "26": [ { "id": "37067557", "name": "somebody", "offset": 26, "length": 10, "type": "page" } ], "48": [ { "id": "50681138", "name": "What do you think about things and stuff?", "offset": 48, "length": 52 } ] }, "icon": "https://fbstatic-a.akamaihd.net/rsrc.php/v2/yg/r/5PpICR5KcPe.png", "actions": [ { "name": "Comment", "link": "https://www.facebook.com/1244414/posts/100085049977" }, { "name": "Like", "link": "https://www.facebook.com/1244414/posts/100085049977" } ], "privacy": { "value": "" }, "type": "question", "object_id": "584616119", "application": { "name": "Questions", "id": "101502535258" }, "created_time": "2013-03-15T19:57:14+0000", "updated_time": "2013-03-15T19:57:14+0000", "likes": { "data": [ { "name": "Kevin Diner", "id": "55520" }, { "name": "Bozo the Clown", "id": "13960" } ], "count": 3 }, "comments": { "data": [ { "id": "14446143_102008355988977_100927", "from": { "name": "Seymour Butts", "id": "505677" }, "message": "seems legit", "created_time": "2013-03-13T12:20:19+0000", "likes": 2 }, { "id": "120143_1020035588977_1019440", "from": { "name": "Andre the Giant", "id": "100390199" }, "message": "Anybody want a peanut?", "created_time": "2013-03-13T12:23:25+0000" } ], "count": 22 } } ], "paging": { "previous": "https://graph.facebook.com/me/home&limit=25&since=1234", "next": "https://graph.facebook.com/me/home&limit=25&until=4321" } } friends-0.2.0+14.04.20140217.1/friends/tests/data/twitter-hashtags.dat0000644000015201777760000000527212300444435025405 0ustar pbusernogroup00000000000000{ "created_at": "Fri Jan 17 14:23:41 +0000 2014", "id": 424185261375766500, "id_str": "424185261375766530", "text": "A service that filters food pictures from Instagram. #MillionDollarIdea", "source": "web", "truncated": false, "in_reply_to_status_id": null, "in_reply_to_status_id_str": null, "in_reply_to_user_id": null, "in_reply_to_user_id_str": null, "in_reply_to_screen_name": null, "user": { "id": 17339829, "id_str": "17339829", "name": "Kai Mast", "screen_name": "Kai_Mast", "location": "Bamberg", "description": "Computer Science Student, Grad School Applicant, C++ Fanboy", "url": "http://t.co/1myW31Mlhl", "entities": { "url": { "urls": [ { "url": "http://t.co/1myW31Mlhl", "expanded_url": "http://kai-mast.de", "display_url": "kai-mast.de", "indices": [ 0, 22 ] } ] }, "description": { "urls": [] } }, "protected": false, "followers_count": 415, "friends_count": 904, "listed_count": 36, "created_at": "Wed Nov 12 14:23:09 +0000 2008", "favourites_count": 939, "utc_offset": 3600, "time_zone": "Berlin", "geo_enabled": true, "verified": false, "statuses_count": 7886, "lang": "en", "contributors_enabled": false, "is_translator": false, "profile_background_color": "8B542B", "profile_background_image_url": "http://abs.twimg.com/images/themes/theme8/bg.gif", "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme8/bg.gif", "profile_background_tile": false, "profile_image_url": "http://pbs.twimg.com/profile_images/424181800701673473/Q6Ggqg7P_normal.png", "profile_image_url_https": "https://pbs.twimg.com/profile_images/424181800701673473/Q6Ggqg7P_normal.png", "profile_banner_url": "https://pbs.twimg.com/profile_banners/17339829/1360006443", "profile_link_color": "9D582E", "profile_sidebar_border_color": "D9B17E", "profile_sidebar_fill_color": "EADEAA", "profile_text_color": "333333", "profile_use_background_image": true, "default_profile": false, "default_profile_image": false, "following": false, "follow_request_sent": false, "notifications": false }, "geo": null, "coordinates": null, "place": null, "contributors": null, "retweet_count": 0, "favorite_count": 0, "entities": { "hashtags": [ { "text": "MillionDollarIdea", "indices": [ 53, 71 ] } ], "symbols": [], "urls": [], "user_mentions": [] }, "favorited": false, "retweeted": false, "lang": "en" } friends-0.2.0+14.04.20140217.1/friends/tests/data/ubuntu.png0000644000015201777760000002122412300444435023434 0ustar pbusernogroup00000000000000‰PNG  IHDR1j³{tEXtSoftwareAdobe ImageReadyqÉe<"6IDATxÚì]lçÆ_» –¯¶ à®[iW*Ä›öb!œTEJ¸‰#u%ªDõé^à•ZR–²RiêRrQØP6´æ&‡ˆ¨‘¶RÌ d•*1+´É:$w[Z§¤M@Ø€ùHhºóŒß±9gæsfÞyŸG:2 æ|Ì™ùÍóÿxÿo› ¨}dÙÁÅYþMÏkNòÈQÔÆCàDÖ?ºc¨û•J/;<&fýy"ús©wùÍ:”Û`é ~ôÊÇb ’8dlÔ˜|DPÁŸ Mò%t(;S‰¦»D1rD#ò'@ô¿yB‡Ò™b©” 0Y@4=ž:T¾YLŸÈ˜Ô%„(B‡RÍF ˜(d¢²iLh8Ð1B‡ªšÇ$hèfò×pô`bšÐ!hˆÐ¡  ÁUù hÌi"†`„Né@Ó s4öi,xÔð`9žÐ)ƒ«Ù.aC¹~Õè~×`Ó/ASáÑpÚý æ~B(8›n‘Ò¹Ÿƒ ½Û`³]>˜.·jp?„¡CØP„¡CØP„Eè6TQ:(áÄ3¡“pPLS%œ>„N+°Ù(O$6ôQª“®ç¡“6k¤³©òhPMj¡8Çl:*Àùó6Tb¾‡Ði,Y¨1”¢ ¹¶si¡Á¦K:›í<¨‚…u]Uß]×Б‰b¸›n^”&MHðxëz¼„Ý E×Cèès7]¡£ 8¨Lä¹NY&¯*\^@G†Sp7}<¿)K5*]OéûzJNöÝPn„[ÛËÞÍÜîA85JàPŽçi-8o_¤Óq3œBœ\åyL9nUʘç)täº)”#Y¢ÊnUÊ–ç)Ux%ó7£U¢pkTŽV!t,N¿`þ†*§çù9Ã+»€óc1ÝaLQ¥†˜®n9çq:2Ó_åùHy"çÌÎB‡ Áã&xœ„ŽΈ`˜òWÎV¶œƒCQnƒÇ)è8T3ºço¿*:Ömó‚ŸsV®«ÄÜà'týÌ[áÏOξ/nþî}qíôqñÙÔe‚‡Ð!p¨ljï\$ºúÄ‚M[nFUS§Ž‹©Ó'ÄÕß¼Bðø ‡Ê ›%Oîjù¹>ÿƒ8ÿü6qã½SOÐ!p(Uu¬Ý –?}(³³Qq>çls!ìrz¦ài5&p(—µtë^-À>· K|ágÃa5Ìraýሜ¸@è(ªFàPiZðÍ-¢ëñ­¯ ð¬|öH˜°v<ÃòN褸¬¥âÒ*QsV¬Köym$ª—ï8äÂaꕎÇ*ðX¹Z¼ÊKŠJ «àÀu˜Rç†ÍaµÌðÔúÀÁ<œA^NTšp±ã¢7­"«e9«Ï¦¹ËV@'¶E¥ Í6èÞu¹âv ª-CGfØGx)Q*B.Ç—c… „½†ŽLpaˆ:GŒRJš¿áQ«ÞMTÔ°éRºi§ƒmbX§ÔCšµY÷ž ±„° ”n :r#¼*/#*Ó¾Î>èÜká{JQ¯¼áû™8>ÈKˆÊt²v.2Z&o$:”ëÉXbY;tbK(ªw»… TTM€Ò;š`☢l‘öüŽVèÈ<—8P”=êšSs4‡y„>ÌFÈ0/Î ´I #nÈyÃæ vu2|åÀ(ß…üÎpÏkŽ•:±=ª(Kˆà17 *BñaåÍ*©Šƒ!X·ÆÏÝ€~+øï¬c@ñï©Â„üNožÊâtûqŒ ½$aÛ~ð0QâÐð˜ýÚØ ºþÞ[¡SJ zëc;¡wxk±4ýB…O”m×#¼ìõ;l»Ò¹~³S}$W^ÿ•8੆!Z¶}žŸßæÒÎiÂ^éÿá,tdX…-P»‰= YøÍ-áR¼‡“ëF‚~ðO_nø÷EÏBnFèÐZÖ„0ê´Ð0«èðjÀ)8>^±*„ º š¸Ðü‡ÏÔè"Æ~T6AçæÙ÷Ê-aVa%sY­ÚN,#ähVþèˆX}ä±là¹R'RÒ¢NTÂp¡Û¢ÉáÃe<½*²½Å-èV« 毪½-¾¸ï˜‹+œ•”¶¨Ó– ¡`‰r9wE)E5 IIV«rr€ÍŠ6“³M€)B¬FÂ…Ž¼i]Ú]æ¯aV!}u¹CGÎê$&r§Ö=TzØÄµpÓ–Ä¿Çv¿&…¯Ä.'Rµˆ¡_E8AÁµU¹ËôE¦[‹ú·yAcáä«CÆÂª¤²~É”»Ûù\Î.g£àR‡B„¦¹öÎ.Ññ÷_÷âó¶ßÓ!þúÉÍÄ®åëo¿aÄ^8´3|mOôù|yþÄ ¿½ö?¶:§@]zy_x—¥Û¹£ñ=ßÑZÍ*Y# rô’gR97§#ý ÑPœþúéÍÀÌsqR]Óngîý_Sÿ=œxL¦N¾*æýÝ× w<žêÀ#p;ÿe t$ñm0—“å¢ îâ+þí°h ..Õ•Ø7lÚbå½"tϪ¯„ D?ýð·‰à‰`PQ)ûè™>ŸBªzúÇ ÌªàiÙjç^¡ °›ÉpëX»A¬ª½–‡±[e–½±/Ýïձ¾I%ô;áç~ñ§]ån!YýÇï=Ìñ2̲ÂéÄ\N¿5-ݺW,êßÃð! #Úæv(ßIqøTBÇñÁâU„Qp5IÂ’„+Çîä\8ÿ§™c„¼^k|OÚ¥½¦Gê ÜÎHàvZZ—Õ–t~,Ø—£Na•ô¼žµuÿþà wT8%t%û$8„9Iã/f+Z›†ÕöXÛ(,ÅsãØcmWÚˆ Ï5ÒóÚ…–ÖeµérÆs9鹉à„p’r1˜-ƒ‹JUX{UÖ¥y‚§Þw…³ “Y•<'›¾ù¶øâÛ œta½Ôý¿|35ù‹$(~WU%oï+¸DÀ»•!$äñ pšRK‘MÓ9ærÔ„üÍÒþ‘òï#WsåxM)à[ÃàíéoVŠÎ‹O?ü¿ÄªU˜º[Éí´âtèrR´|Ç ¢ëñlwÀ a6Žª|kŒ§Ï?ûRbªT¶({ÜNS9ærRHÞ¹(,ƒ/Üôí¦Ÿ#Ë4ºE}[Ù:¾ н<<$&ƒÃ%½Ž§™ ƒÍ:*Ó8È9´œÐ%=}Hùw/¶bÔƒI׃i‚è{B8›§óÉÒ?E·S¬Óù½`3`Cà4*‰gÕŸ÷|G\;}Béwç¯4 7¨i¡Êuå7¯ˆgNejìÃwˆ¼Zgp<ç¯ßn•ÓjµŒn§EèÈ5V5ëbÁ½ Võ„·q§[„v„[rï­zJÚû+2}™ÝNŸ 7ƒëYáÐŽZ©êéÒÑ}a{¿Š|lÔ霞ºšnG¹š‘ :rØú(³àDÊ’TÎó½ A…&ØﳩäóªCº¬6Ú–'l}–ñeÂÄñŸö‡­6º4eRj!¡£ „3¶õ%Äš-TàrÙæzçCâžݲðÔtZM‡U¶% ÃmQ¾ÿ o¿„\p=¶%šÑÉ0+ݨ´·ò}ò'¶„U¸Àp¡±vZH4cyˆM³„p¾P„NÓBµªÈ…œYƒ Œ]°3…æ<$™uîgž¤¬ÃõKªÄ*V{BhµÑ÷Ð*mý’.á‚BƒÝ¯Ã-‹1mq;œ8ØØ°´ÓåÔîV6¬Šæ8uð`õ· àÉ:\ŸÐ¹£Šß.Ç|%‚ÀÉ.[À—ìù.9™B :2óvq'JŸ¦×ý8e÷%ôJ§ã­ËA,¾È°5&pÊ!|w;„Ž‚‹›,‘GU*'ð˜®jyîvú²@ÇÛ$²I—Càä/Ó}<ž»îz¥ó» #[˜½,•£beÒå\ÚͲxÎ »—÷ôí\öÜíTTœŽ¿ dƒ'–6°ñ¯äº)a¢Ç};JЩøxdÐ}lªb…¼—6+ÝTbî“=U/¡Ó@¦š¹`û¹¥‰¾ðÕT~ÇãfÁÞÙý:3 #ÿ²Û·£‚DŸ©Ñ—^ÞÇ<Ž&…]ËÏo3òÚpÑpÓt;w;/ó9ØÅTXuyø0i QØDÐT˜µÐß«’†VŰÊ\˜e¢š…ò¹§ e:¸0tÉDw[†UæÂ,„µF\õúÍ>rBgÆÝÇÀìÜe/ÝϫߠÖšH*wú9f¶;žL¾ _“È&ò9—‡‡œÛªŒ2~,|±ÚZé ­àr&èPæ…ÞnÇ÷Ëkèt¬Ó_¼úú+\[å¹Û¹w—¥óîzÐñn½U§;]ÝÎU|; ºÇ‘¢bÅ\Ž…ày]ïš7,‹ðp«šºÐéöí(èn»ò:tÚyèÿ^:ü ±nGRs\–*ÌY‰Çj1wåù$×ϼæHšéy‰šótl3 nXÊ>Á}bÇPKaî]ûwÝèØa¦çµ 'çÈÿXc㛜ÞwjKpWx¨a•iIìÏÐÔéã™’µºÀsíÔ ^Ýkêô ­Ðé°`§Sj·Ñå`˜ÖªÚÛâ‹ûŽ…0P-k#G³là9Ñýë³á†öªÛtÌÓ½ÂY9VK÷,#äu<œ(X±:H®Ý÷‹7ÄŠ§µÜ?X­>òŽò¦öXSÔ<]„V\òà€Û B,­NÞS·c t‡ûù¦˜×³6ßç}rW²´»J´Y[ Z¹béT<7é£Ó1*„BEná Ý-­L 7RÄBÀëï½Å+ÚÝ8£÷{òÝéTLGGõ1ô~6œ ž"ê>™©æ„*–ÎFÁ9þ9nãNgéÖ½Z€3+™æ¼Z ‘²Þõ(Êi…Î==vŽhôpt$EùkBB¿›û(ŠÐѪÏXö¦(B‡¢(B§´º×ãY¶åÎ[Ò^½š²æÃ8Ió‹qÇkE69:*ÃE‘2C;_èœóïøê~ALÜ/z7MU]:šœo™¿áÑÖïšt;Ή!uÉ £r±ëyûB6ÎúGsÃAè8Ziþ¾<\f3AgT·Û¹0ôCcŸeò´0/m”©ò]“Ð!täÛ2›ž×.|AgB÷‹cS;Ý{GCHïéOÌåäQ2½}÷:N…Vš¡ãccªÑeçlËm)U]ÚÚ‘çÇ\Fá–tšóp†öD:£&ÞÜztçãç·¥N„C.'oPø¸¡š“wàÎEZ“ÈÎÐCgÂÔ»ˆÀSd¨…;ʇß{88EmH·ãŠËÑû=ù:ÎÖ8t"ðŒÿ´?L.çm9³sÕ•î*N½5I;NPö¨Só÷tÓs§3jÃ;Br€È£íåÚõX³¤¤q¤%Oì6ò¹`Ù±É e·æ¯ß¬õõ< ¯&¬q:³]ÏùO‰±oõ„Î'K¾. Àl²©V|sK®Éãº'ôº»ó¨ör_s:á‚Ïž×.¼{ö‘eV½;ÀÎäZÐ?åFðçöà‰öÂÞAø›ùœO*üó, ^Ÿ…bh9q5q:RcÁ£ÛÆw ÁµäÝÓ°¨o«X6ðœ–Ï€ °ä®Ÿö 75Ý¡Õ ?¡3#§AÇ«Mp"u£l ­6k­|t:AD59:£>€hË`Ý'·ÎÌ”š–<¹Sûkz؉uÜ×Ðj„Й%S;T`)W ëÜ¥‰\Ž©PÞz§#ã.ï’ɸû˜êÅ8 &•5o ëìê UÒ«~æsR뻬/ºbh@„YE £ê‡U¦ªÙ´É¤fœȄNü¤îB¦fÖ"ÌZÄuY… ‹mM…U&Cx tO˜.<1°ø”{d#„¯+Ÿ=bÐEÿÊ×ò]¡U]è`¶Žð0¯ÝLNèÇ…ÁüNÇõ%#Õ*Ó¡»KNÇ[·3=8ÌœÛÁ…ñ…Ÿ <9jùŽŒÆGÂǽ­¤Æ¤‰!tÒîJ&݆}±'!q¼pÓ·¾l_M—Cè$ ±÷eÃI?\(¸CSÍKÇÜkºœ¡#-ј¯GÊtn‡ài8+ž6߆à¹Ë†³8¯ÝŽéÜÁã>pP±òÜåŒFãI³@gØç#·cb!(ÁӼñ8pÉ—Žî§Ëi †Ð (uÌç#·sqh·ïà¹ïo°ª• €Ùt'\²Ç}9ÍC‡ngzì…-SûQÕºï—o²pö €m¦«T‘àŽ=î>ŽT·TNè(êüóÛ¬y/QÇN ˆMöáÔ;_T¶±öÕå: ‚M¶© ¢È[øžçAþæþ8&;g órð”ñÞø nK’[·»±-œºs~0¬Rå…*t&|?’Ø®æÒËv„Yx>àØ*fUííÐÝèÞþWE“¯…E*=´‚ÚTžåì#Ë^ ~Ty<…Xù£#áüSB˜÷ÇïÃØ46:›ø÷ñÑ3}t9wB«/åátbͲÑ&›³„U®Î_l·ùâ¾cVg:ósGU~I :²QpŒÇtºip|O¿‘×ÎÒZà|þÙ—¦Ã’'vZ¿Ý úmÐ €&HÛa¿ 즲™“6Õg B¬?y\§¥{îªçª*ßU›Ùåd”t±#MÂÇÎà1ýf+ó5„<ÎÅûy!Ä€˜“Çó†Îº™Zºu¯èz\ÏN†~(.Vú]Ì[ÆøÓ$E¾Ú:[öáh:ã"hâÇnü§ý¼fªOuéT[–g Àó*žœÇ÷ŽÐ¤Wt ~–ä1.êUµw2]ÌÈQ>×Ïœ·ðçÜÐ5ŒÇ¼àѱnC¸œÃe1q\WJ äHs2>yй;®/ª/¦,Éc쑞Õ= ›»iõ xD·ÆÏ‰Oξ\`ÓýIIëÐîéùªø\çôëÂÉ~®¦œ œ†\PV[ÖgÜÎïƒÝ<Î3ݪ-E\dY¬<’Å«¼Ã/¤!§à0q\W‹ÍÎÉÃé@ƒYÉVvá·2oðdí<^òäN~)¡QäRààæ¬\%Ú¥+„[mä œd—“8ÍB'êP^Ìã],x²ÌeAo‹-ãlq&ÈS!YX¨ŽðA¹U5|Nªfýmͼ ËçŇZÈœ«~Mù÷ñš.ô¶-ä°£GmQor5ÔHàrÎ|4k©x¼“O««Ò³ÌñÁ]Ùwà6ÚõXxìóêClœD5e<Úš}5®Ç*Îñdí©×è‹àhαqÃå´âtš¦œoŽÉš‹È”<~b§·ÀAW0BPLjšŽvš†N@¹f¥ƒŽk¦T•%y 7µ¨oÀ»ã 0ÿyÏw¸ ÁœÐ xD;tèvÔ…•È+äh*`J¡ºËÙåä2‚VÉù5FÕÒußÖê«3·£.”c‘çi $BUC›-_£‰vtJ£7gv8Š$4–Ü ž÷Ú©L§»œ/µòy@‡ A³XË $ZùìKwU›²&M³8€2–„,Ø´%³Dåqrø°U«ñ-R%€ÎI£Ð‘àùyðc;¿u! ÇõÇï=œ©3g|’ª Ô—ìÍ¥Q2 wî'|î¨éŠÕŒï(Ço‚߉º»ù0 îªYw„´m'„¢…±*ÀA¿VØçÕ™0 3“swÕÛ×yËjËëݰKYt3-ä[V¥©èÙF\¡>¤K—Ó°cŒX(8,ólQ§Jg6f=L y!8·uÎ-}’täJS:"ãY# šÿÒÂNCÔ1(ûò삎†Fˆ‡üå[# ™´}Æjê^Yǣτhb%¹6èämèXÎb`¯W€×NOœÐ‡’8މ á{X¾Ã›¼Úö¬ór´CGîc|˜ ZJÕ‰aÕÓ‡ŒB=R®î+–A#­,wÐétÂP0©œ«°”âý†ë¸lÙS½(¡ )—uÛà>Ëîr Iñ¤ÒŽ1ÌÊY¸ì…Þ“;©+)Íx¶ô)!©_b·3(£–ÜÕV仿–5Å+Ú¬®L£JÑ4Ù¨ƶ5g%Ý«åõU&«HUÁNåB…ÕÖp?cßê W²gßc£’ðæo°ËY ·SÂJVµÈ'/:2̪ Å •¬Â]7 ×ò?I{k…ùzû¹v”kTìÁVt ¯fÙ!4³!ÃÅ‘´ÕŠIÐLï0úVê«5ÿù[ëÞÿ¥£û2ÍA²9¬ ½y—Èï ‘5Ú5| n[c(\ CyaÄ·úÅΜº@×¾—³ï‡³kT·†¹m˃0ÆÆ^¥u(÷ mÐÁ ÜÀ3LØ¡«³Ã„µŸQ9¯æÄ–]Ô€‡ÉíP/ö߀Kôß­nÇkëÅÝ^ަͪU¦œÀs,šYJ·TQhÃAçÞ M€?ÑiÝ4 £üŽ)Ê¡º\Õê u¾X¬šÅ2:EÙ¡jž+Èmt:ÑÚ,†XTfÝçÀôœ…ò¸ö¹·í&>©\DVãwNe‚Ž¥»4ÜHé-²T£Áuø¯&^¸ÝÔ'>ðwó;TF]·ð¿éÞS¤7*¦^¼Ýð‡¯æw(Ç]…ƒN§¢£ÇJèÈ^á¥D©jʲ=±Ô¤Õþ#ͪêêDZÕéD‰å*/'JEh2´)Äšrk{ãZC¹œƒŽ§ RJºòº›ßaž‘CñÈ<ªqµÛrDd&½ÆKŠJ.tÜNÚHU‹„‚5 ®Û-;8Û+Z”‚.í6úú€ž#.'¬T™L[ Xb™à¡…ÜFJ˜¸ªlHà¸átâàa)Jo^ު׭ó¶YÛ¨X8ïÚöÆÚmǧ3ËõôËp‹Õ-Ê °ù‰O¸ÍÇoYV·àz*<ç)C }eM:áƒ$ó ]¥Y¥Ã™ôñ÷ùþíÓõPšÝ ’Å'}>m<èz(-ò.wC訧KZß*•“F‚Çvs7„N6øl”!W7Õ¤&$lŽðP: ¹¨ÂC)1ÝY<ÉCAè4rá$ÚΣA¥hXº›x(<à³F§ʣAÍÒˆ˜NŸä¡ tŠ€ÏF Ÿ †÷,:„¥ 6ƒL:„¥#Œª6„Ž-ðaΧܰaΆб>ª],µ»­š˜.}³±Ðq>(µ÷IøpV³;‹Á†}6„޳Ú(ÝOݵBMÍ—AZ„ÝeÎÕ`­Ý0ú´& n­ ‰\ s5„Ž·z@¨ˆ !t(S HÓ• &hJ@]< !†aÉnfD>†Yy"t¨ü\P¡^ÏC±2p4#t3„¥Ï õ΂PÝÐDùs”Õ&B‡²D€¢?»Ò4"]LädÆB‡rHå+³~vkrH‘còçDü'ó0„å·SŠk±PËEeÆÿ£S¡âúT!ÝÍÁIEND®B`‚friends-0.2.0+14.04.20140217.1/friends/tests/data/flickr-xml-error.dat0000644000015201777760000000025312300444435025274 0ustar pbusernogroup00000000000000 friends-0.2.0+14.04.20140217.1/friends/tests/data/facebook-contacts.dat0000644000015201777760000000111012300444435025453 0ustar pbusernogroup00000000000000{ "data": [ { "name": "Jane Doe", "id": "123456" }, { "name": "John Doe", "id": "654321" }, { "name": "Jim Doe", "id": "987654" }, { "name": "Joe Doe", "id": "876543" }, { "name": "Tim Tom", "id": "765432" }, { "name": "Tom Dunne", "id": "111111" }, { "name": "Pete Wilson", "id": "222222" }, { "name": "John Smith", "id": "444444" } ] } friends-0.2.0+14.04.20140217.1/friends/tests/data/json-utf-16be.dat0000644000015201777760000000005612300444435024376 0ustar pbusernogroup00000000000000{"yes": "\u00d1\u00d8"}friends-0.2.0+14.04.20140217.1/friends/tests/test_twitter.py0000644000015201777760000010361312300444435023611 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Twitter plugin.""" __all__ = [ 'TestTwitter', ] import os import tempfile import unittest import shutil from urllib.error import HTTPError from friends.protocols.twitter import RateLimiter, Twitter from friends.tests.mocks import FakeAccount, FakeSoupMessage, LogMock from friends.tests.mocks import TestModel, mock from friends.utils.cache import JsonCache from friends.errors import AuthorizationError @mock.patch('friends.utils.http._soup', mock.Mock()) @mock.patch('friends.utils.base.notify', mock.Mock()) class TestTwitter(unittest.TestCase): """Test the Twitter API.""" def setUp(self): self._temp_cache = tempfile.mkdtemp() self._root = JsonCache._root = os.path.join( self._temp_cache, '{}.json') TestModel.clear() self.account = FakeAccount() self.protocol = Twitter(self.account) self.log_mock = LogMock('friends.utils.base', 'friends.protocols.twitter') def tearDown(self): # Ensure that any log entries we haven't tested just get consumed so # as to isolate out test logger from other tests. self.log_mock.stop() shutil.rmtree(self._temp_cache) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch.dict('friends.utils.authentication.__dict__', LOGIN_TIMEOUT=1) @mock.patch('friends.utils.authentication.Signon.AuthSession.new') @mock.patch('friends.protocols.twitter.Downloader.get_json', return_value=None) def test_unsuccessful_authentication(self, dload, login, *mocks): self.assertRaises(AuthorizationError, self.protocol._login) self.assertIsNone(self.account.user_name) self.assertIsNone(self.account.user_id) @mock.patch('friends.utils.authentication.manager') @mock.patch('friends.utils.authentication.Accounts') @mock.patch('friends.utils.authentication.Authentication.__init__', return_value=None) @mock.patch('friends.utils.authentication.Authentication.login', return_value=dict(AccessToken='some clever fake data', TokenSecret='sssssshhh!', UserId='1234', ScreenName='stephenfry')) def test_successful_authentication(self, *mocks): self.assertTrue(self.protocol._login()) self.assertEqual(self.account.user_name, 'stephenfry') self.assertEqual(self.account.user_id, '1234') self.assertEqual(self.account.access_token, 'some clever fake data') self.assertEqual(self.account.secret_token, 'sssssshhh!') @mock.patch('friends.protocols.twitter.Downloader') @mock.patch('oauthlib.oauth1.rfc5849.generate_nonce', lambda: 'once upon a nonce') @mock.patch('oauthlib.oauth1.rfc5849.generate_timestamp', lambda: '1348690628') def test_signatures(self, dload): self.account.secret_token = 'alpha' self.account.access_token = 'omega' self.account.consumer_secret = 'obey' self.account.consumer_key = 'consume' self.account.auth.get_credentials_id = lambda *ignore: 6 self.account.auth.get_method = lambda *ignore: 'oauth2' self.account.auth.get_mechanism = lambda *ignore: 'HMAC-SHA1' result = '''\ OAuth oauth_nonce="once%20upon%20a%20nonce", \ oauth_timestamp="1348690628", \ oauth_version="1.0", \ oauth_signature_method="HMAC-SHA1", \ oauth_consumer_key="consume", \ oauth_token="omega", \ oauth_signature="klnMTp3hH%2Fl3b5%2BmPtBlv%2BCulic%3D"''' self.protocol._rate_limiter = 'limits' class fake: def get_json(): return None dload.return_value = fake self.protocol._get_url('http://example.com') dload.assert_called_once_with( 'http://example.com', headers=dict(Authorization=result), rate_limiter='limits', params=None, method='GET') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-home.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_home(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual(self.protocol.home(), 3) self.assertEqual(3, TestModel.get_n_rows()) # This test data was ripped directly from Twitter's API docs. expected = [ ['twitter', 88, '240558470661799936', 'messages', 'OAuth Dancer', '119476949', 'oauth_dancer', False, '2012-08-28T21:16:23Z', 'just another test', 'https://si0.twimg.com/profile_images/730275945/oauth-dancer.jpg', 'https://twitter.com/oauth_dancer/status/240558470661799936', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ], ['twitter', 88, '240556426106372096', 'images', 'Raffi Krikorian', '8285392', 'raffi', False, '2012-08-28T21:08:15Z', 'lecturing at the "analyzing big data ' 'with twitter" class at @Cal' ' with @othman ' '' 'pic.twitter.com/rJC5Pxsu', 'https://si0.twimg.com/profile_images/1270234259/' 'raffi-headshot-casual.png', 'https://twitter.com/raffi/status/240556426106372096', 0, False, 'http://p.twimg.com/AZVLmp-CIAAbkyy.jpg', '', '', '', '', '', '', 0.0, 0.0, ], ['twitter', 88, '240539141056638977', 'messages', 'Taylor Singletary', '819797', 'episod', False, '2012-08-28T19:59:34Z', 'You\'d be right more often if you thought you were wrong.', 'https://si0.twimg.com/profile_images/2546730059/' 'f6a8zq58mg1hn0ha8vie.jpeg', 'https://twitter.com/episod/status/240539141056638977', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ], ] for i, expected_row in enumerate(expected): self.assertEqual(list(TestModel.get_row(i)), expected_row) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-home.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_home_since_id(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.assertEqual(self.protocol.home(), 3) with open(self._root.format('twitter_ids'), 'r') as fd: self.assertEqual(fd.read(), '{"messages": 240558470661799936}') get_url = self.protocol._get_url = mock.Mock() get_url.return_value = [] self.assertEqual(self.protocol.home(), 3) get_url.assert_called_once_with( 'https://api.twitter.com/1.1/statuses/' + 'home_timeline.json?count=50&since_id=240558470661799936') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-send.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_from_me(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.account.user_name = 'oauth_dancer' self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual( self.protocol.send('some message'), 'https://twitter.com/oauth_dancer/status/240558470661799936') self.assertEqual(1, TestModel.get_n_rows()) # This test data was ripped directly from Twitter's API docs. expected_row = [ 'twitter', 88, '240558470661799936', 'messages', 'OAuth Dancer', '119476949', 'oauth_dancer', True, '2012-08-28T21:16:23Z', 'just another test', 'https://si0.twimg.com/profile_images/730275945/oauth-dancer.jpg', 'https://twitter.com/oauth_dancer/status/240558470661799936', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ] self.assertEqual(list(TestModel.get_row(0)), expected_row) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_home_url(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.home(), 0) publish.assert_called_with('tweet') get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/home_timeline.json?count=50') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_mentions(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.mentions(), 0) publish.assert_called_with('tweet', stream='mentions') get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/' + 'mentions_timeline.json?count=50') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_user(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.user(), 0) publish.assert_called_with('tweet', stream='messages') get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_list(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.list('some_list_id'), 0) publish.assert_called_with('tweet', stream='list/some_list_id') get_url.assert_called_with( 'https://api.twitter.com/1.1/lists/statuses.json?list_id=some_list_id') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_lists(self): get_url = self.protocol._get_url = mock.Mock( return_value=[dict(id_str='twitlist')]) publish = self.protocol.list = mock.Mock() self.assertEqual(self.protocol.lists(), 0) publish.assert_called_with('twitlist') get_url.assert_called_with( 'https://api.twitter.com/1.1/lists/list.json') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_private(self): get_url = self.protocol._get_url = mock.Mock(return_value=['tweet']) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.private(), 0) publish.assert_called_with('tweet', stream='private') self.assertEqual( get_url.mock_calls, [mock.call('https://api.twitter.com/1.1/' + 'direct_messages.json?count=50'), mock.call('https://api.twitter.com/1.1/' + 'direct_messages/sent.json?count=50') ]) def test_private_avatars(self): get_url = self.protocol._get_url = mock.Mock( return_value=[ dict( created_at='Sun Nov 04 17:14:52 2012', text='Does my avatar show up?', id_str='1452456', sender=dict( screen_name='some_guy', name='Bob', profile_image_url_https='https://example.com/bob.jpg', ), )]) publish = self.protocol._publish = mock.Mock() self.protocol.private() publish.assert_called_with( liked=False, sender='Bob', stream='private', url='https://twitter.com/some_guy/status/1452456', icon_uri='https://example.com/bob.jpg', link_picture='', sender_nick='some_guy', sender_id='', from_me=False, timestamp='2012-11-04T17:14:52Z', message='Does my avatar show up?', message_id='1452456') self.assertEqual( get_url.mock_calls, [mock.call('https://api.twitter.com/1.1/' + 'direct_messages.json?count=50'), mock.call('https://api.twitter.com/1.1/' + 'direct_messages/sent.json?count=50&since_id=1452456') ]) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_send_private(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._publish_tweet = mock.Mock( return_value='https://twitter.com/screen_name/status/tweet_id') self.assertEqual( self.protocol.send_private('pumpichank', 'Are you mocking me?'), 'https://twitter.com/screen_name/status/tweet_id') publish.assert_called_with('tweet', stream='private') get_url.assert_called_with( 'https://api.twitter.com/1.1/direct_messages/new.json', dict(text='Are you mocking me?', screen_name='pumpichank')) def test_failing_send_private(self): def fail(*ignore): raise HTTPError('url', 403, 'Forbidden', 'Forbidden', mock.Mock()) with mock.patch.object(self.protocol, '_get_url', side_effect=fail): self.assertRaises( HTTPError, self.protocol.send_private, 'pumpichank', 'Are you mocking me?', ) def test_send(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._publish_tweet = mock.Mock( return_value='https://twitter.com/u/status/id') self.assertEqual( self.protocol.send('Hello, twitterverse!'), 'https://twitter.com/u/status/id') publish.assert_called_with('tweet') get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/update.json', dict(status='Hello, twitterverse!')) def test_send_thread(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._publish_tweet = mock.Mock( return_value='tweet permalink') self.assertEqual( self.protocol.send_thread( '1234', 'Why yes, I would love to respond to your tweet @pumpichank!'), 'tweet permalink') publish.assert_called_with('tweet', stream='reply_to/1234') get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/update.json', dict(status='Why yes, I would love to respond to your ' 'tweet @pumpichank!', in_reply_to_status_id='1234')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-home.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_send_thread_prepend_nick(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual(self.protocol.home(), 3) self.assertEqual(3, TestModel.get_n_rows()) # If you forgot to @mention in your reply, we add it for you. get = self.protocol._get_url = mock.Mock() self.protocol._publish_tweet = mock.Mock() self.protocol.send_thread( '240556426106372096', 'Exciting and original response!') get.assert_called_once_with( 'https://api.twitter.com/1.1/statuses/update.json', dict(status='@raffi Exciting and original response!', in_reply_to_status_id='240556426106372096')) # If you remembered the @mention, we won't duplicate it. get.reset_mock() self.protocol.send_thread( '240556426106372096', 'You are the greatest, @raffi!') get.assert_called_once_with( 'https://api.twitter.com/1.1/statuses/update.json', dict(status='You are the greatest, @raffi!', in_reply_to_status_id='240556426106372096')) def test_delete(self): get_url = self.protocol._get_url = mock.Mock(return_value='tweet') publish = self.protocol._unpublish = mock.Mock() self.assertEqual(self.protocol.delete('1234'), '1234') publish.assert_called_with('1234') get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/destroy/1234.json', dict(trim_user='true')) def test_retweet(self): tweet = dict(tweet='twit') get_url = self.protocol._get_url = mock.Mock(return_value=tweet) publish = self.protocol._publish_tweet = mock.Mock( return_value='tweet permalink') self.assertEqual(self.protocol.retweet('1234'), 'tweet permalink') publish.assert_called_with(tweet) get_url.assert_called_with( 'https://api.twitter.com/1.1/statuses/retweet/1234.json', dict(trim_user='false')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-retweet.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_retweet_with_data(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.account.user_name = 'therealrobru' self.account.auth.parameters = dict( ConsumerKey='key', ConsumerSecret='secret') self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual( self.protocol.retweet('240558470661799936'), 'https://twitter.com/therealrobru/status/324220250889543682') self.assertEqual(1, TestModel.get_n_rows()) self.maxDiff = None expected_row = [ 'twitter', 88, '324220250889543682', 'messages', 'Robert Bruce', '836242932', 'therealrobru', True, '2013-04-16T17:58:26Z', 'RT @tarek_ziade: Just found a "Notification ' 'of Inspection" card in the bottom of my bag. looks like they were ' 'curious about those raspberry-pi :O', 'https://si0.twimg.com/profile_images/2631306428/' '2a509db8a05b4310394b832d34a137a4.png', 'https://twitter.com/therealrobru/status/324220250889543682', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ] self.assertEqual(list(TestModel.get_row(0)), expected_row) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-multiple-links.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_multiple_links(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.account.user_name = 'Independent' self.account.auth.parameters = dict( ConsumerKey='key', ConsumerSecret='secret') self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual( self.protocol.send('some message'), 'https://twitter.com/Independent/status/426318539796930560') self.assertEqual(1, TestModel.get_n_rows()) self.maxDiff = None expected_row = [ 'twitter', 88, '426318539796930560', 'messages', 'The Independent', '16973333', 'Independent', True, '2014-01-23T11:40:35Z', 'An old people\'s home has recreated famous movie scenes for a wonderful calendar ' 'ind.pn/1g3wX9q ' 'pic.twitter.com/JxQTPG7WLL', 'https://pbs.twimg.com/profile_images/378800000706113664/d1a957578723e496c025be1e2577d06d.jpeg', 'https://twitter.com/Independent/status/426318539796930560', 0, False, 'http://pbs.twimg.com/media/BeqWc_-CIAAhmdc.jpg', '', '', '', '', '', '', 0.0, 0.0, ] self.assertEqual(list(TestModel.get_row(0)), expected_row) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.http.Soup.Message', FakeSoupMessage('friends.tests.data', 'twitter-hashtags.dat')) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch('friends.utils.base._seen_ids', {}) def test_multiple_links(self, *mocks): self.account.access_token = 'access' self.account.secret_token = 'secret' self.account.user_name = 'Independent' self.account.auth.parameters = dict( ConsumerKey='key', ConsumerSecret='secret') self.assertEqual(0, TestModel.get_n_rows()) self.assertEqual( self.protocol.send('some message'), 'https://twitter.com/Kai_Mast/status/424185261375766530') self.assertEqual(1, TestModel.get_n_rows()) self.maxDiff = None expected_row = [ 'twitter', 88, '424185261375766530', 'messages', 'Kai Mast', '17339829', 'Kai_Mast', False, '2014-01-17T14:23:41Z', 'A service that filters food pictures from Instagram. ' '#MillionDollarIdea', 'https://pbs.twimg.com/profile_images/424181800701673473/Q6Ggqg7P.png', 'https://twitter.com/Kai_Mast/status/424185261375766530', 0, False, '', '', '', '', '', '', '', 0.0, 0.0, ] self.assertEqual(list(TestModel.get_row(0)), expected_row) def test_unfollow(self): get_url = self.protocol._get_url = mock.Mock() self.assertEqual(self.protocol.unfollow('pumpichank'), 'pumpichank') get_url.assert_called_with( 'https://api.twitter.com/1.1/friendships/destroy.json', dict(screen_name='pumpichank')) def test_follow(self): get_url = self.protocol._get_url = mock.Mock() self.assertEqual(self.protocol.follow('pumpichank'), 'pumpichank') get_url.assert_called_with( 'https://api.twitter.com/1.1/friendships/create.json', dict(screen_name='pumpichank', follow='true')) def test_like(self): get_url = self.protocol._get_url = mock.Mock() inc_cell = self.protocol._inc_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.like('1234'), '1234') inc_cell.assert_called_once_with('1234', 'likes') set_cell.assert_called_once_with('1234', 'liked', True) get_url.assert_called_with( 'https://api.twitter.com/1.1/favorites/create.json', dict(id='1234')) def test_unlike(self): get_url = self.protocol._get_url = mock.Mock() dec_cell = self.protocol._dec_cell = mock.Mock() set_cell = self.protocol._set_cell = mock.Mock() self.assertEqual(self.protocol.unlike('1234'), '1234') dec_cell.assert_called_once_with('1234', 'likes') set_cell.assert_called_once_with('1234', 'liked', False) get_url.assert_called_with( 'https://api.twitter.com/1.1/favorites/destroy.json', dict(id='1234')) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_tag(self): get_url = self.protocol._get_url = mock.Mock( return_value=dict(statuses=['tweet'])) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.tag('yegbike'), 0) publish.assert_called_with('tweet', stream='search/#yegbike') get_url.assert_called_with( 'https://api.twitter.com/1.1/search/tweets.json?q=%23yegbike') self.assertEqual(self.protocol.tag('#yegbike'), 0) publish.assert_called_with('tweet', stream='search/#yegbike') get_url.assert_called_with( 'https://api.twitter.com/1.1/search/tweets.json?q=%23yegbike') @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.utils.base._seen_ids', {}) def test_search(self): get_url = self.protocol._get_url = mock.Mock( return_value=dict(statuses=['tweet'])) publish = self.protocol._publish_tweet = mock.Mock() self.assertEqual(self.protocol.search('hello'), 0) publish.assert_called_with('tweet', stream='search/hello') get_url.assert_called_with( 'https://api.twitter.com/1.1/search/tweets.json?q=hello') @mock.patch('friends.protocols.twitter.time.sleep') def test_rate_limiter_first_time(self, sleep): # The first time we see a URL, there is no rate limiting. limiter = RateLimiter() message = FakeSoupMessage('friends.tests.data', 'twitter-home.dat') message.new('GET', 'http://example.com/') limiter.wait(message) sleep.assert_called_with(0) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_second_time(self, time, sleep): # The second time we see the URL, we get rate limited. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 1, }) limiter.update(message.new('GET', 'http://example.com')) limiter.wait(message) sleep.assert_called_with(300) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_second_time_with_query(self, time, sleep): # A query parameter on the second request is ignored. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 1, }) limiter.update(message.new('GET', 'http://example.com/foo?baz=7')) limiter.wait(message) sleep.assert_called_with(300) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_second_time_with_query_on_request(self, time, sleep): # A query parameter on the original request is ignored. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 1, }) limiter.update(message.new('GET', 'http://example.com/foo?baz=7')) limiter.wait(message) sleep.assert_called_with(300) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_maximum(self, time, sleep): # With one remaining call this window, we get rate limited to the # full amount of the remaining window. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 1, }) limiter.update(message.new('GET', 'http://example.com/alpha')) limiter.wait(message) sleep.assert_called_with(300) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_until_end_of_window(self, time, sleep): # With no remaining calls left this window, we wait until the end of # the window. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 0, }) limiter.update(message.new('GET', 'http://example.com/alpha')) limiter.wait(message) sleep.assert_called_with(300) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_medium(self, time, sleep): # With a few calls remaining this window, we time slice the remaining # time evenly between those remaining calls. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 3, }) limiter.update(message.new('GET', 'http://example.com/beta')) limiter.wait(message) sleep.assert_called_with(100.0) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_rate_limiter_unlimited(self, time, sleep): # With more than 5 calls remaining in this window, we don't rate # limit, even if we've already seen this url. limiter = RateLimiter() message = FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 10, }) limiter.update(message.new('GET', 'http://example.com/omega')) limiter.wait(message) sleep.assert_called_with(0) @mock.patch('friends.utils.base.Model', TestModel) @mock.patch('friends.protocols.twitter.Twitter._login', return_value=True) @mock.patch( 'friends.utils.http.Soup.Message', FakeSoupMessage( 'friends.tests.data', 'twitter-home.dat', headers={ 'X-Rate-Limit-Reset': 1349382153 + 300, 'X-Rate-Limit-Remaining': 3, })) @mock.patch('friends.protocols.twitter.time.sleep') @mock.patch('friends.protocols.twitter.time.time', return_value=1349382153) def test_protocol_rate_limiting(self, time, sleep, login): self.account.access_token = 'access' self.account.secret_token = 'secret' # Test rate limiting via the Twitter plugin API. # # The first call doesn't get rate limited. self.protocol.home() sleep.assert_called_with(0) # Second call gets called with the established limit. Because there # are three more calls allowed within the current window, and we're # reporting 300 seconds left in the current window, we're saying we'll # sleep 100 seconds between each call. self.protocol.home() sleep.assert_called_with(100.0) def test_contacts(self): get = self.protocol._get_url = mock.Mock( return_value=dict(ids=[1,2],name='Bob',screen_name='bobby')) prev = self.protocol._previously_stored_contact = mock.Mock(return_value=False) push = self.protocol._push_to_eds = mock.Mock() self.assertEqual(self.protocol.contacts(), 2) self.assertEqual( get.call_args_list, [mock.call('https://api.twitter.com/1.1/friends/ids.json'), mock.call(url='https://api.twitter.com/1.1/users/show.json?user_id=1'), mock.call(url='https://api.twitter.com/1.1/users/show.json?user_id=2')]) self.assertEqual( prev.call_args_list, [mock.call('1'), mock.call('2')]) self.assertEqual( push.call_args_list, [mock.call(link='https://twitter.com/bobby', uid='1', name='Bob', nick='bobby'), mock.call(link='https://twitter.com/bobby', uid='2', name='Bob', nick='bobby')]) friends-0.2.0+14.04.20140217.1/friends/tests/test_logging.py0000644000015201777760000000537312300444435023541 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the logging utilities.""" __all__ = [ 'TestLogging', ] import os import shutil import logging import tempfile import unittest from friends.utils.logging import initialize from friends.tests.mocks import mock class TestLogging(unittest.TestCase): """Test the logging utilities.""" @mock.patch('friends.utils.logging.logging') @mock.patch('friends.utils.logging.os') def test_initialize(self, os_mock, log_mock): os_mock.path.dirname.return_value = '/dev' initialize(filename='/dev/null') os_mock.makedirs.assert_called_once_with('/dev') os_mock.path.dirname.assert_called_once_with('/dev/null') rot = log_mock.handlers.RotatingFileHandler rot.assert_called_once_with( '/dev/null', maxBytes=20971520, backupCount=5) log_mock.Formatter.assert_called_with( '{levelname:5} {threadName:10} {asctime} {name:18} {message}', style='{') rot().setFormatter.assert_called_once_with(log_mock.Formatter()) log_mock.getLogger.assert_called_once_with() log_mock.getLogger().setLevel.assert_called_once_with(log_mock.INFO) log_mock.getLogger().addHandler.assert_called_once_with(rot()) @mock.patch('friends.utils.logging.logging') @mock.patch('friends.utils.logging.os') def test_initialize_console(self, os_mock, log_mock): os_mock.path.dirname.return_value = '/dev' initialize(True, True, filename='/dev/null') os_mock.makedirs.assert_called_once_with('/dev') os_mock.path.dirname.assert_called_once_with('/dev/null') stream = log_mock.StreamHandler stream.assert_called_once_with() log_mock.Formatter.assert_called_with( '{levelname:5} {threadName:10} {name:18} {message}', style='{') stream().setFormatter.assert_called_once_with(log_mock.Formatter()) log_mock.getLogger.assert_called_once_with() log_mock.getLogger().setLevel.assert_called_once_with(log_mock.DEBUG) log_mock.getLogger().addHandler.assert_called_with(stream()) friends-0.2.0+14.04.20140217.1/friends/tests/test_time.py0000644000015201777760000001106612300444435023045 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the time parsing utilities.""" __all__ = [ 'TestParseTime', ] import unittest from friends.utils.time import iso8601utc, parsetime class TestParseTime(unittest.TestCase): def test_type(self): # parsetime() should always return int seconds since the epoch. self.assertTrue(isinstance(parsetime('2012-05-10T13:36:45Z'), int)) def test_parse_naive(self): # ISO 8601 standard format without timezone. self.assertEqual(parsetime('2012-05-10T13:36:45'), 1336657005) def test_parse_utctz_alt(self): # ISO 8601 standard format with UTC timezone. self.assertEqual(parsetime('2012-05-10T13:36:45 +0000'), 1336657005) def test_parse_utctz(self): # ISO 8601 standard format with UTC timezone. self.assertEqual(parsetime('2012-05-10T13:36:45Z'), 1336657005) def test_parse_naive_altsep(self): # ISO 8601 alternative format without timezone. self.assertEqual(parsetime('2012-05-10 13:36:45'), 1336657005) def test_parse_utctz_altsep(self): # ISO 8601 alternative format with UTC timezone. self.assertEqual(parsetime('2012-05-10T13:36:45 +0000'), 1336657005) def test_bad_time_string(self): # Odd unsupported format. self.assertRaises(ValueError, parsetime, '2012/05/10 13:36:45') def test_non_utc(self): # Non-UTC timezones are get converted to UTC, before conversion to # epoch seconds. self.assertEqual(parsetime('2012-05-10T13:36:45 -0400'), 1336671405) def test_nonstandard_twitter(self): # Sigh. Twitter has to be different. self.assertEqual(parsetime('Thu May 10 13:36:45 +0000 2012'), 1336657005) def test_nonstandard_twitter_non_utc(self): # Sigh. Twitter has to be different. self.assertEqual(parsetime('Thu May 10 13:36:45 -0400 2012'), 1336671405) def test_nonstandard_facebook(self): # Sigh. Facebook gets close, but no cigar. self.assertEqual(parsetime('2012-05-10T13:36:45+0000'), 1336657005) def test_identica(self): self.assertEqual(parsetime('Fri, 05 Oct 2012 08:46:39'), 1349426799) def test_multiple_timezones(self): # Multiple timezone strings are not supported. self.assertRaises(ValueError, parsetime, '2012-05-10T13:36:45 +0000 -0400') def test_iso8601_utc(self): # Convert a Unix epoch time seconds in UTC (the default) to an ISO # 8601 UTC date time string. self.assertEqual(iso8601utc(1336657005), '2012-05-10T13:36:45Z') def test_iso8601_utc_with_sep(self): # Convert a Unix epoch time seconds in UTC (the default) to an ISO # 8601 UTC date time string with a different separator. self.assertEqual(iso8601utc(1336657005, sep=' '), '2012-05-10 13:36:45') def test_iso8601_west_of_utc(self): # Convert a Unix epoch time seconds plus an offset to an ISO 8601 UTC # date time string. self.assertEqual(iso8601utc(1336657005, -400), '2012-05-10T17:36:45Z') def test_iso8601_west_of_utc_with_sep(self): # Convert a Unix epoch time seconds plus an offset to an ISO 8601 UTC # date time string, with a different separator. self.assertEqual(iso8601utc(1336657005, -400, sep=' '), '2012-05-10 17:36:45') def test_iso8601_east_of_utc(self): # Convert a Unix epoch time seconds plus an offset to an ISO 8601 UTC # date time string. self.assertEqual(iso8601utc(1336657005, 130), '2012-05-10T12:06:45Z') def test_iso8601_east_of_utc_with_sep(self): # Convert a Unix epoch time seconds plus an offset to an ISO 8601 UTC # date time string, with a different separator. self.assertEqual(iso8601utc(1336657005, 130, sep=' '), '2012-05-10 12:06:45') friends-0.2.0+14.04.20140217.1/friends/tests/test_model.py0000644000015201777760000000717512300444435023215 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the Dee.SharedModel that we use for communicating with our frontend. This does not test the use of the SharedModel through dbus, since that must be done in test_dbus.py so as to be isolated from the user's environment. """ __all__ = [ 'TestModel', ] import unittest from friends.utils.model import prune_model, persist_model from friends.tests.mocks import LogMock, mock class TestModel(unittest.TestCase): """Test our Dee.SharedModel instance.""" def setUp(self): self.log_mock = LogMock('friends.utils.model') def tearDown(self): self.log_mock.stop() @mock.patch('friends.utils.model.Model') def test_persist_model(self, model): model.__len__.return_value = 500 model.is_synchronized.return_value = True persist_model() model.is_synchronized.assert_called_once_with() model.flush_revision_queue.assert_called_once_with() self.assertEqual(self.log_mock.empty(), 'Trying to save Dee.SharedModel with 500 rows.\n' + 'Saving Dee.SharedModel with 500 rows.\n') @mock.patch('friends.utils.model.Model') @mock.patch('friends.utils.model.persist_model') def test_prune_one(self, persist, model): model.get_n_rows.return_value = 8001 def side_effect(arg): model.get_n_rows.return_value -= 1 model.remove.side_effect = side_effect prune_model(8000) persist.assert_called_once_with() model.get_first_iter.assert_called_once_with() model.remove.assert_called_once_with(model.get_first_iter()) self.assertEqual(self.log_mock.empty(), 'Deleted 1 rows from Dee.SharedModel.\n') @mock.patch('friends.utils.model.Model') @mock.patch('friends.utils.model.persist_model') def test_prune_one_hundred(self, persist, model): model.get_n_rows.return_value = 8100 def side_effect(arg): model.get_n_rows.return_value -= 1 model.remove.side_effect = side_effect prune_model(8000) persist.assert_called_once_with() self.assertEqual(model.get_first_iter.call_count, 100) model.remove.assert_called_with(model.get_first_iter()) self.assertEqual(model.remove.call_count, 100) self.assertEqual(self.log_mock.empty(), 'Deleted 100 rows from Dee.SharedModel.\n') @mock.patch('friends.utils.model.Model') @mock.patch('friends.utils.model.persist_model') def test_prune_none(self, persist, model): model.get_n_rows.return_value = 100 def side_effect(arg): model.get_n_rows.return_value -= 1 model.remove.side_effect = side_effect prune_model(8000) model.get_n_rows.assert_called_once_with() self.assertFalse(persist.called) self.assertFalse(model.get_first_iter.called) self.assertFalse(model.remove.called) self.assertEqual(self.log_mock.empty(), '') friends-0.2.0+14.04.20140217.1/friends/tests/test_threads.py0000644000015201777760000000674612300444435023552 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test callbacks in our threading architecture.""" __all__ = [ 'TestThreads', ] import unittest import threading from friends.tests.mocks import mock from friends.utils.base import _OperationThread def join_all_threads(): current = threading.current_thread() for thread in threading.enumerate(): if thread != current: thread.join() def exception_raiser(exception): """Used for testing that the failure callback gets called.""" raise exception def it_cant_fail(): """Used for testing that the success callback gets called.""" return 1 + 1 def adder(a, b): """Used for testing that argument passing works with subthreads.""" return a + b @mock.patch('friends.utils.base.notify', mock.Mock()) class TestThreads(unittest.TestCase): """Test protocol implementations.""" def test_exception_calls_failure_callback(self): success = mock.Mock() failure = mock.Mock() err = ValueError('This value is bad, and you should feel bad!') _OperationThread( id='Test.thread', target=exception_raiser, success=success, failure=failure, args=(err,), ).start() # Wait for threads to exit, avoiding race condition. join_all_threads() failure.assert_called_once_with(str(err)) self.assertEqual(success.call_count, 0) def test_no_exception_calls_success_callback(self): success = mock.Mock() failure = mock.Mock() _OperationThread( id='Test.thread', target=it_cant_fail, success=success, failure=failure, ).start() # Wait for threads to exit, avoiding race condition. join_all_threads() success.assert_called_once_with('2') self.assertEqual(failure.call_count, 0) def test_can_pass_args_to_operations(self): success = mock.Mock() failure = mock.Mock() _OperationThread( id='Test.thread', target=adder, success=success, failure=failure, args=(5, 7), ).start() # Wait for threads to exit, avoiding race condition. join_all_threads() success.assert_called_once_with('12') self.assertEqual(failure.call_count, 0) def test_can_pass_kwargs_to_operations(self): success = mock.Mock() failure = mock.Mock() _OperationThread( id='Test.thread', target=adder, success=success, failure=failure, kwargs=dict(a=5, b=7), ).start() # Wait for threads to exit, avoiding race condition. join_all_threads() success.assert_called_once_with('12') self.assertEqual(failure.call_count, 0) friends-0.2.0+14.04.20140217.1/friends/tests/test_cli.py0000644000015201777760000000660212300444435022656 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Test the command line interface for friends-dispatcher.""" __all__ = [ 'TestCommandLine', ] import unittest from itertools import product from friends.utils.options import Options from friends.tests.mocks import mock class TestCommandLine(unittest.TestCase): """Test the command line.""" def setUp(self): self.options = Options() def test_no_args(self): args = self.options.parser.parse_args([]) self.assertFalse(args.debug) self.assertFalse(args.console) def test_debug_args(self): args = self.options.parser.parse_args(['--debug']) self.assertTrue(args.debug) self.assertFalse(args.console) args = self.options.parser.parse_args(['-d']) self.assertTrue(args.debug) self.assertFalse(args.console) def test_console_args(self): args = self.options.parser.parse_args(['--console']) self.assertFalse(args.debug) self.assertTrue(args.console) args = self.options.parser.parse_args(['-o']) self.assertFalse(args.debug) self.assertTrue(args.console) def test_all_flags(self): # Test all combinations of flag arguments. for options in product(('-d', '--debug'), ('-o', '--console')): # argparse requires a list not a tuple. options = list(options) args = self.options.parser.parse_args(options) self.assertTrue(args.debug) self.assertTrue(args.console) @mock.patch('argparse._sys.exit') @mock.patch('argparse._sys.stderr') def test_bad_args(self, stderr, exit): # Bad arguments. # # By default, argparse will print a message to stderr and exit when it # gets the bad argument. We could derive from the Options class and # override a bunch of methods, but it's a bit easier to just mock two # methods to capture the state. self.options.parser.parse_args(['--noargument']) # In the case of the stderr mock, test suite invocation messes with # the error message we expect. Rather than instrument or deriving # from the Options class to accept a special usage string, we just # test the tail of the error message. called_with = stderr.write.call_args # Exactly one positional argument. self.assertEqual(len(called_with[0]), 1) # No keyword arguments. self.assertFalse(called_with[1]) # And the positional argument contains the error message. self.assertEqual(called_with[0][0][-44:-1], 'error: unrecognized arguments: --noargument') # parse_args() really tried to exit with error code 2. exit.assert_called_once_with(2) friends-0.2.0+14.04.20140217.1/friends/utils/0000755000015201777760000000000012300444701020464 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/utils/__init__.py0000644000015201777760000000000012300444435022567 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/utils/model.py0000644000015201777760000000627512300444435022154 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The Dee.SharedModel interface layer. Dee.SharedModel is comparable to a Gtk.ListStore, except that it shares its state between processes using DBus. When friends-dispatcher downloads new messages from a website, it inserts those messages into this SharedModel instance, which then triggers a callback in the Vala frontend, which knows to display the new messages there. """ __all__ = [ 'Schema', 'Model', 'MODEL_DBUS_NAME', 'persist_model', 'prune_model', ] from gi.repository import Dee import logging log = logging.getLogger(__name__) class Schema: """Represents the DeeModel schema data that we defined in CSV.""" DEFAULTS = { 'b': False, 's': '', 'd': 0, 't': 0, } FILES = [ 'data/model-schema.csv', '/usr/share/friends/model-schema.csv', ] def __init__(self): """Parse CSV from disk.""" self.COLUMNS = [] self.NAMES = [] self.TYPES = [] self.INDICES = {} files = self.FILES[:] while files: filename = files.pop() log.debug('Looking for SCHEMA in {}'.format(filename)) try: with open(filename) as schema: for col in schema: name, variant = col.rstrip().split(',') self.COLUMNS.append((name, variant)) self.NAMES.append(name) self.TYPES.append(variant) log.debug( 'Found {} columns for SCHEMA'.format(len(self.COLUMNS))) break except IOError: pass self.INDICES = {name: i for i, name in enumerate(self.NAMES)} MODEL_DBUS_NAME = 'com.canonical.Friends.Streams' Model = Dee.SharedModel.new(MODEL_DBUS_NAME) def persist_model(): """Write our Dee.SharedModel instance to disk.""" log.debug('Trying to save Dee.SharedModel with {} rows.'.format(len(Model))) if Model is not None and Model.is_synchronized(): log.debug('Saving Dee.SharedModel with {} rows.'.format(len(Model))) Model.flush_revision_queue() def prune_model(maximum): """If there are more than maximum rows, remove the oldest ones.""" pruned = 0 while Model.get_n_rows() > maximum: Model.remove(Model.get_first_iter()) pruned += 1 if pruned: log.debug('Deleted {} rows from Dee.SharedModel.'.format(pruned)) # Delete those messages from disk, too, not just memory. persist_model() friends-0.2.0+14.04.20140217.1/friends/utils/base.py0000644000015201777760000006327112300444435021765 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Protocol base class and manager.""" __all__ = [ 'Base', 'feature', 'initialize_caches', ] import re import time import logging import threading from datetime import datetime, timedelta from oauthlib.oauth1 import Client from gi.repository import GLib, GObject, EDataServer, EBook, EBookContacts from friends.errors import FriendsError, ContactsError, ignored from friends.utils.authentication import Authentication from friends.utils.model import Schema, Model, persist_model from friends.utils.notify import notify from friends.utils.time import ISO8601_FORMAT FIVE_DAYS_AGO = (datetime.now() - timedelta(5)).isoformat() STUB = lambda *ignore, **kwignore: None COMMA_SPACE = ', ' SCHEMA = Schema() AVATAR_IDX = SCHEMA.INDICES['icon_uri'] FROM_ME_IDX = SCHEMA.INDICES['from_me'] STREAM_IDX = SCHEMA.INDICES['stream'] SENDER_IDX = SCHEMA.INDICES['sender'] MESSAGE_IDX = SCHEMA.INDICES['message'] ID_IDX = SCHEMA.INDICES['message_id'] ACCT_IDX = SCHEMA.INDICES['account_id'] TIME_IDX = SCHEMA.INDICES['timestamp'] # See friends/tests/test_protocols.py for further documentation LINKIFY_REGEX = re.compile( r""" # Do not match if URL is preceded by quotes, slash, or '>' # This is used to prevent duplication of linkification. (?/]) # Record everything that we're about to match. ( # URLs can start with 'http://', 'https://', 'ftp://', or 'www.' (?:(?:https?|ftp)://|www\.) # Match many non-whitespace characters, but not greedily. (?:\S+?) # Stop recording the match. ) # This section will peek ahead (without matching) in order to # determine precisely where the URL actually *ends*. (?= # Do not include any trailing punctuation, if any are present. [.,!?\"\'\)\<\>]* # With "trailing" defined as immediately preceding the first # space, or end-of-string. (?:\s|$) # But abort the whole thing if the URL ends with a quote or angle # bracket, again to prevent duplication of linkification. (?![\'\"\<\>]+) )""", flags=re.VERBOSE).sub # This is a mapping from message_ids to DeeModel row index ints. It is # used for quickly and easily preventing the same message from being # published multiple times by mistake. _seen_ids = {} # Protocol __call__() methods run in threads, so we need to serialize # publishing new data into the SharedModel. _publish_lock = threading.Lock() log = logging.getLogger(__name__) def feature(method): """Decorator for marking a method as a public feature. Use like so: @feature def method(self): # ... Then find all feature methods for a protocol with: for feature_name in ProtocolClass.get_features(): # ... """ method.is_feature = True return method def initialize_caches(): """Populate _seen_ids with Model data. Our Dee.SharedModel persists across instances, so we need to populate this cache at launch. """ # Don't create a new dict; we need to keep the same dict object in # memory since it gets imported into a few different places that # would not get the updated reference to the new dict. _seen_ids.clear() _seen_ids.update({row[ID_IDX]: i for i, row in enumerate(Model)}) log.debug('_seen_ids: {}'.format(len(_seen_ids))) def linkify_string(string): """Finds all URLs in a string and turns them into HTML links.""" return LINKIFY_REGEX(r'\1', string) class _OperationThread(threading.Thread): """Manage async callbacks, and log subthread exceptions.""" def __init__(self, *args, id=None, success=STUB, failure=STUB, **kws): self._id = id self._success_callback = success self._failure_callback = failure # Wrap the real target inside retval_catcher method = kws.get('target') kws['args'] = (method,) + kws.get('args', ()) kws['target'] = self._retval_catcher super().__init__(*args, **kws) def _retval_catcher(self, func, *args, **kwargs): """Call the success callback, but only if no exceptions were raised.""" self._success_callback(str(func(*args, **kwargs))) def run(self): log.debug('{} is starting in a new thread.'.format(self._id)) start = time.time() try: super().run() except Exception as err: # Raising an exception is the only way for a protocol # operation to avoid triggering the success callback. self._failure_callback(str(err)) log.exception(err) elapsed = time.time() - start log.debug('{} has completed in {:.2f}s, thread exiting.'.format( self._id, elapsed)) class Base: """Parent class for any protocol plugin such as Facebook or Twitter. In order to add support for a new social network (hereafter referred to as a "protocol") to Friends, you must first ensure that Ubuntu Online Accounts supports your protocol, then create a new class that subclasses this one, and then override as many methods as necessary until your protocol functions as desired. Please refer to protocols/facebook.py and protocols/twitter.py for relatively complete examples of how to do this. This documentation will identify which methods are necessary to override in order to build a working protocol plugin. If you find that some of the code in this class is not actually compatible with the protocol you are trying to implement, it should be straightforward to override it, however this should be unlikely. The code in this class has been tested against Facebook, Twitter, Flickr, Identica, and Foursquare, and works well with all of them. """ # Lazily populated when needed for contact syncing. _book_client = None _address_book_name = None _eds_source_registry = None _eds_source = None # This number serves a guideline (not a hard limit) for the protocol # subclasses to download in each refresh. _DOWNLOAD_LIMIT = 50 # Default to not notify any messages. This gets overridden from main.py, # which is the only place we can safely access gsettings from. _do_notify = lambda protocol, stream: False def __init__(self, account): self._account = account self._Name = self.__class__.__name__ self._name = self._Name.lower() def _whoami(self, result): """Use OAuth login results to identify the authenticating user. This method gets called with the OAuth server's login response, and must be used to populate self._account.user_id and self._account.user_name variables. Each protocol must override this method to accomplish this task specifically for the social network being implemented. For example, Twitter provides this information directly and simply needs to be assigned to the variables; Facebook does not provide this information and thus it's necessary to initiate an additional HTTP request within this method in order to discover that information. This method will be called only once, immediately after a successful OAuth authentication, and you can safely assume that self._account.access_token will already be populated with a valid access token by the time this method is invoked. :param result: An already-instantiated JSON object, typically in the form of a dict, typically containing an AccessToken key and potentially others. :type result: dict """ raise NotImplementedError( '{} protocol has no _whoami() method.'.format( self._Name)) def receive(self): """Poll the social network for new messages. Friends will periodically invoke this method on your protocol in order to fetch new messages and publish them into the Dee.SharedModel. This method must be implemented by all subclasses. It is expected to initiate an HTTP request to the social network, interpret the results, and then call self._publish() with the interpreted results. Typically, this method (and other similar ones that you may implement at your option) will start with a call to self._get_access_token(), as this is the recommended way to initiate only a single login attempt (all subsequent calls return the cached access token without re-authenticating). The Friends-dispatcher will invoke these methods asynchronously, in a sub-thread, but we have designed the threading architecture in a very orthogonal way, so it should be very easy for you to write the methods in a straightforward synchronous way. If you need to indicate that there is an error condition (any error condition at all), just raise an exception (any exception will do, as long as it is a subclass of the builtin Exception class). Friends-dispatcher will automatically log the exception and indicate the error condition to the user. If you need to return a value (such as the destination URL that a successfully uploaded photo has been uploaded to), you can simply return the value, and Friends-dispatcher will catch that return value and invoke a callback in the calling code for you automatically. Only a single return value is supported, and it must be converted into a string to be sent over DBus. """ raise NotImplementedError( '{} protocol has no receive() method.'.format( self._Name)) def __call__(self, operation, *args, success=STUB, failure=STUB, **kwargs): """Call an operation, i.e. a method, with arguments in a sub-thread. If a protocol method raises an exception, that will be caught and passed to the failure callback; if no exception is raised, then the return value of the method will be passed to the success callback. Programs communicating with friends-dispatcher via DBus should therefore specify success & failure callbacks in order to be notified of the results of their DBus method calls. :param operation: The name of the instance method to invoke in a sub-thread. :type operation: string :param args: The arguments you wish to pass to that method. :type args: tuple :param kwargs: Keyword arguments you wish to pass to that method. :type kwargs: dict :param success: A callback to invoke in the event of successful asynchronous completion. :type success: callable :param failure: A callback to invoke in the event of an exception being raised in the sub-thread. :type failure: callable :return: None """ if operation.startswith('_') or not hasattr(self, operation): raise NotImplementedError(operation) method = getattr(self, operation) _OperationThread( id='{}.{}'.format(self._Name, operation), target=method, success=success, failure=failure, args=args, kwargs=kwargs, ).start() def _get_n_rows(self): """Return the number of rows in the Dee.SharedModel.""" return len(Model) def _publish(self, **kwargs): """Publish fresh data into the model, ignoring duplicates. This method inserts a new full row into the Dee.SharedModel that we use for storing and sharing tweets/messages/posts/etc. Rows cannot (easily) be modified once inserted, so if you need to process a lot of information in order to construct a row, it is easiest to invoke this method like so: args = {} args['message_id'] = '1234' args['message'] = 'hello.' args['from_me'] = is_from_me() #etc self._publish(**args) :param message_id: The service-specific id of the message being published. Serves as the third component of the unique 'message_ids' column. :type message_id: string :param kwargs: The additional column name/values to be published into the model. Not all columns must be given, but it is an error if any non-column keys are given. Refer to utils/model.py to see the schema which defines the valid arguments to this method. :raises: TypeError if non-column names are given in kwargs. :return: True if the message was appended to the model or already present. Otherwise, False is returned if the message could not be appended. """ # These bits don't need to be set by the caller; we can infer them. kwargs.update( dict( protocol=self._name, account_id=self._account.id ) ) # linkify the message orig_message = kwargs.get('message', '') kwargs['message'] = linkify_string(orig_message) args = [] # Now iterate through all the column names listed in the # SCHEMA, and pop matching column values from the kwargs, in # the order which they appear in the SCHEMA. If any are left # over at the end of this, raise a TypeError indicating the # unexpected column names. for column_name, column_type in SCHEMA.COLUMNS: args.append(kwargs.pop(column_name, SCHEMA.DEFAULTS[column_type])) if len(kwargs) > 0: raise TypeError('Unexpected keyword arguments: {}'.format( COMMA_SPACE.join(sorted(kwargs)))) with _publish_lock: message_id = args[ID_IDX] # Don't let duplicate messages into the model if message_id not in _seen_ids: _seen_ids[message_id] = Model.get_position(Model.append(*args)) # Don't notify messages from me, or older than five days. if args[FROM_ME_IDX] or args[TIME_IDX] < FIVE_DAYS_AGO: return True # Check if notifications are enabled before notifying. if self._do_notify(args[STREAM_IDX]): notify( args[SENDER_IDX], orig_message, args[AVATAR_IDX], ) return message_id in _seen_ids def _unpublish(self, message_id): """Remove message_id from the Dee.SharedModel. :param message_id: The service-specific id of the message being published. :type message_id: string """ log.debug('Unpublishing {}!'.format(message_id)) row_idx = _seen_ids.pop(message_id, None) if row_idx is None: raise FriendsError('Tried to delete an invalid message id.') Model.remove(Model.get_iter_at_row(row_idx)) # Shift our cached indexes up one, when one gets deleted. for key, value in _seen_ids.items(): if value > row_idx: _seen_ids[key] = value - 1 def _get_access_token(self): """Return an access token, logging in if necessary. :return: The access_token, if we are successfully logged in. """ if self._account.access_token is None: self._login() return self._account.access_token def _login(self): """Prevent redundant login attempts. This method implements some tricky threading logic in order to avoid race conditions, and it should not be overridden any subclass. If you need to modify the way logging in functions in order to make your protocol login correctly, you should override _locked_login() instead. :return: True if we are already logged in, or if a new login was successful. """ # The first time the user logs in, we expect old_token to be None. # Because this code can be executed in multiple threads, we first # acquire a lock and then try to log in. The act of logging in also # sets an access token. This is all part of the libaccounts API. # # The check of the access token prevents the following race condition: # + Thread A sees no access token so it is not logged in. # + Thread B sees no access token so it is not logged in. # + Thread A and B both try to acquire the login lock, and A wins # + Thread A sees that the access token has not changed, so it knows # that it won the race. It logs in, getting a new, different access # token. Since that does not match the pre-lock token, thread A # returns True. # + As Thread A is returning, it releases the lock (*after* it's # calculated the return value). # + Thread B acquires the lock and sees that the access token has # changed because thread A is already logged in. It does not try to # log in again, but also returns True since the access token has # changed. IOW, thread B is also already logged in via thread A. old_token = self._account.access_token with self._account.login_lock: if self._account.access_token == old_token: self._locked_login(old_token) # This test must be performed while the login lock is acquired, # otherwise it's possible for another thread to come in between # the release of the login lock and the test, and change the # access token. return self._account.access_token != old_token def _locked_login(self, old_token): """Synchronous login implementation. Subclasses should only need to implement _whoami() in order to handle the protocol-specific details of a login operation, however this method can be overridden if you need a greater degree of control over the login process. It is safe to assume that this method will only be called once, the first time any subthread needs to log in. You do not have to worry about subthread race conditions inside this method. """ log.debug('{} to {}'.format( 'Re-authenticating' if old_token else 'Logging in', self._Name)) result = Authentication(self._account.id).login() self._account.access_token = result.get('AccessToken') self._whoami(result) log.debug('{} UID: {}'.format(self._Name, self._account.user_id)) def _get_oauth_headers(self, method, url, data=None, headers=None): """Basic wrapper around oauthlib that we use for Twitter and Flickr.""" # "Client" == "Consumer" in oauthlib parlance. key = self._account.consumer_key secret = self._account.consumer_secret # "resource_owner" == secret and token. resource_owner_key = self._get_access_token() resource_owner_secret = self._account.secret_token oauth_client = Client( key, secret, resource_owner_key, resource_owner_secret) headers = headers or {} if data is not None: headers['Content-Type'] = 'application/x-www-form-urlencoded' # All we care about is the headers, which will contain the # Authorization header necessary to satisfy OAuth. uri, headers, body = oauth_client.sign( url, body=data, headers=headers or {}, http_method=method) return headers def _is_error(self, data): """Is the return data an error response?""" try: error = data.get('error') or data.get('errors') except AttributeError: return False if error is None: return False try: message = error.get('message') except AttributeError: message = None raise FriendsError(message or str(error)) def _calculate_row_cell(self, message_id, column_name): """Find x,y coords in the model based on message_id and column_name.""" row_id = _seen_ids.get(message_id) col_idx = SCHEMA.INDICES.get(column_name) if None in (row_id, col_idx): raise FriendsError('Cell could not be found.') return row_id, col_idx def _fetch_cell(self, message_id, column_name): """Find a column value associated with a specific message_id.""" row_id, col_idx = self._calculate_row_cell(message_id, column_name) return Model.get_row(row_id)[col_idx] def _set_cell(self, message_id, column_name, value): """Set a column value associated with a specific message_id.""" row_id, col_idx = self._calculate_row_cell(message_id, column_name) Model.get_row(row_id)[col_idx] = value persist_model() def _inc_cell(self, message_id, column_name): """Increment a column value associated with a specific message_id.""" row_id, col_idx = self._calculate_row_cell(message_id, column_name) Model.get_row(row_id)[col_idx] += 1 persist_model() def _dec_cell(self, message_id, column_name): """Decrement a column value associated with a specific message_id.""" row_id, col_idx = self._calculate_row_cell(message_id, column_name) Model.get_row(row_id)[col_idx] -= 1 persist_model() def _prepare_eds_connections(self, allow_creation=True): """Lazily establish a connection to EDS.""" if None not in (self._address_book_name, self._eds_source_registry, self._eds_source, self._book_client): return self._address_book_name = 'friends-{}-contacts'.format(self._name) self._eds_source_registry = EDataServer.SourceRegistry.new_sync(None) self._eds_source = self._eds_source_registry.ref_source( self._address_book_name) # First run? Might need to create the EDS source! if allow_creation and self._eds_source is None: self._eds_source = EDataServer.Source.new_with_uid( self._address_book_name, None) self._eds_source.set_display_name(self._address_book_name) self._eds_source.set_parent('local-stub') self._eds_source.get_extension( EDataServer.SOURCE_EXTENSION_ADDRESS_BOOK ).set_backend_name('local') self._eds_source_registry.commit_source_sync(self._eds_source, None) if self._eds_source is not None: self._book_client = EBook.BookClient.connect_sync(self._eds_source, None) def _push_to_eds(self, **contact_details): self._prepare_eds_connections() contact = self._create_contact(**contact_details) if not self._book_client.add_contact_sync(contact, None): raise ContactsError('Failed to save contact {!r}'.format(contact)) def _previously_stored_contact(self, search_term): self._prepare_eds_connections() query = EBookContacts.BookQuery.vcard_field_test( self._name + '-id', EBookContacts.BookQueryTest.IS, search_term) success, result = self._book_client.get_contacts_sync(query.to_string(), None) if not success: raise ContactsError( 'Id field is missing in {} address book.'.format(self._Name)) return len(result) > 0 def _create_contact(self, uid, name, nick=None, link=None, gender=None, **folks): """Build a VCard based on a dict representation of a contact.""" contact = EBookContacts.Contact.new() folks.update({ 'remote-full-name': name, self._name + '-id': uid, self._name + '-name': name, self._name + '-nick': nick, }) def add_attr(key, value=None, **params): attr = EBookContacts.VCardAttribute.new( 'social-networking-attributes', key) if value is not None: attr.add_value(value) elif params: for subkey, subval in params.items(): if subval is not None: param = EBookContacts.VCardAttributeParam.new(subkey) param.add_value(subval) attr.add_param(param); else: return contact.add_attribute(attr) add_attr(self._name + '-id', uid) add_attr('X-GENDER', gender) add_attr('X-URIS', link) add_attr('X-FOLKS-WEB-SERVICES-IDS', **folks) contact.set_property('full-name', name) if nick: contact.set_property('nickname', nick) # X-TREME debugging! # print(contact.to_string(EBookContacts.VCardFormat(1))) return contact @feature def delete_contacts(self): """Remove all synced contacts from this social network.""" self._prepare_eds_connections(allow_creation=False) with ignored(GLib.GError, AttributeError): return self._eds_source.remove_sync(None) @classmethod def get_features(cls): """Report what public operations we expose over DBus.""" features = [] for name in dir(cls): if getattr(getattr(cls, name), 'is_feature', False): features.append(name) return sorted(features) friends-0.2.0+14.04.20140217.1/friends/utils/time.py0000644000015201777760000001327312300444435022006 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Time utilities.""" __all__ = [ 'parsetime', 'iso8601utc', ] import re import time import locale from calendar import timegm from contextlib import contextmanager from datetime import datetime, timedelta from friends.errors import ignored # Date time formats. Assume no microseconds and no timezone. ISO8601_FORMAT = '%Y-%m-%dT%H:%M:%S' ISO8601_UTC_FORMAT = '%Y-%m-%dT%H:%M:%SZ' TWITTER_FORMAT = '%a %b %d %H:%M:%S %Y' IDENTICA_FORMAT = '%a, %d %b %Y %H:%M:%S' @contextmanager def _c_locale(): locale.setlocale(locale.LC_TIME, 'C') try: yield finally: locale.setlocale(locale.LC_TIME, '') def _from_iso8601(t): return datetime.strptime(t, ISO8601_FORMAT) def _from_iso8601_utc(t): return datetime.strptime(t, ISO8601_UTC_FORMAT) def _from_iso8601alt(t): return datetime.strptime(t, ISO8601_FORMAT.replace('T', ' ')) def _from_twitter(t): return datetime.strptime(t, TWITTER_FORMAT) def _from_identica(t): return datetime.strptime(t, IDENTICA_FORMAT) def _fromutctimestamp(t): return datetime.utcfromtimestamp(float(t)) PARSERS = (_from_iso8601, _from_iso8601_utc, _from_iso8601alt, _from_twitter, _from_identica, _fromutctimestamp) def parsetime(t): """Parse an ISO 8601 datetime string and return seconds since epoch. This accepts either a naive (i.e. timezone-less) string or a timezone aware string. The timezone must start with a + or - and must be followed by exactly four digits. This string is parsed and converted to UTC. This value is then converted to an integer seconds since epoch. """ with _c_locale(): # In Python 3.2, strptime() is implemented in Python, so in order to # parse the UTC timezone (e.g. +0000), you'd think we could just # append %z on the format. We can't rely on it though because of the # non-ISO 8601 formats that some APIs use (I'm looking at you Twitter # and Facebook). We'll use a regular expression to tear out the # timezone string and do the conversion ourselves. tz_offset = None def capture_tz(match_object): nonlocal tz_offset tz_string = match_object.group('tz') if tz_string is not None: # It's possible that we'll see more than one substring # matching the timezone pattern. It should be highly unlikely # so we won't test for that here, at least not now. # # The tz_offset is positive, so it must be subtracted from the # naive datetime in order to return it to UTC. E.g. # # 13:00 -0400 is 17:00 +0000 # or # 1300 - (-0400 / 100) if tz_offset is not None: # This is not the first time we're seeing a timezone. raise ValueError('Unsupported time string: {0}'.format(t)) tz_offset = timedelta(hours=int(tz_string) / 100) # Return the empty string so as to remove the timezone pattern # from the string we're going to parse. return '' # Parse the time string, calling capture_tz() for each timezone match # group we find. The callback itself will ensure we see no more # than one timezone string. naive_t = re.sub(r'[ ]*(?P[-+]\d{4})', capture_tz, t) if tz_offset is None: # No timezone string was found. tz_offset = timedelta() for parser in PARSERS: with ignored(ValueError): parsed_dt = parser(naive_t) break else: # Nothing matched. raise ValueError('Unsupported time string: {0}'.format(t)) # We must have gotten a valid datetime. Normalize out the timezone # offset and convert it to Epoch seconds. Use timegm() to give us # UTC-based conversion from a struct_time to seconds-since-epoch. utc_dt = parsed_dt - tz_offset timetup = utc_dt.timetuple() return int(timegm(timetup)) def iso8601utc(timestamp, timezone_offset=0, sep='T'): """Convert from a Unix epoch timestamp to an ISO 8601 date time string. :param timestamp: Unix epoch timestamp in seconds. :type timestamp: float :param timezone_offset: Offset in hours*100 east/west of UTC. E.g. -400 means 4 hours west of UTC; 130 means 1.5 hours east of UTC. :type timezone_offset: int :param sep: ISO 8601 separator placed between the date and time portions of the result. :type sep: string of length 1. :return: ISO 8601 datetime string. :rtype: string """ dt = datetime.utcfromtimestamp(timestamp) hours_east, minutes_east = divmod(timezone_offset, 100) correction = timedelta(hours=hours_east, minutes=minutes_east) # Subtract the correction to move closer to UTC, since the offset is # positive when east of UTC and negative when west of UTC. return (dt - correction).isoformat(sep=sep) + ('Z' if sep == 'T' else '') friends-0.2.0+14.04.20140217.1/friends/utils/options.py0000644000015201777760000000350612300444435022541 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """friends-dispatcher command line option parsing.""" __all__ = [ 'Options', ] import argparse class Options: """Command line options parsing.""" def __init__(self): self.parser = argparse.ArgumentParser( description='The Friends backend dbus service.') self.parser.add_argument( '-t', '--test', action='store_true', default=False, help='Replace friends-dispatcher with a crash test dummy.') self.parser.add_argument( '-d', '--debug', action='store_true', default=False, help='Enable debug level log messages.') self.parser.add_argument( '-o', '--console', action='store_true', default=False, help='Enable logging to standard output.') self.parser.add_argument( '-p', '--performance', action='store_true', default=False, help='Enable performance tuning instrumentation.') self.parser.add_argument( '--list-protocols', action='store_true', default=False, help='List all the known protocols and exit.') friends-0.2.0+14.04.20140217.1/friends/utils/menus.py0000644000015201777760000000555612300444435022204 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Manage the Unity and indicator menus over dbus.""" import logging import subprocess from friends.errors import ignored MessagingMenu = None """ Disable messaging menu integration until we have some sort of handler with ignored(ImportError): from gi.repository import MessagingMenu """ log = logging.getLogger(__name__) def helper(executable): """Return a callback that executes something in a subprocess. :param executable: The name of the system executable to run. It will be searched for on the parent process's $PATH. :type executable: string :return: A callable useful as a connection function. """ def _helper(*ignore): try: output = subprocess.check_output( [executable], # Open stdin, stdout, and stderr as text streams. stderr=subprocess.PIPE, universal_newlines=True) except subprocess.CallProcessError: log.exception(helper) # Only log the output if there is any. if len(output) > 0: log.info('{}: {}', helper, output) # Return a suitable closure. return _helper class MenuManager: """Manage the indicator menus over dbus.""" messaging = None def __init__(self, refresh_callback, shutdown_callback): self._refresh = refresh_callback self._shutdown = shutdown_callback # Only do the menu initializations if they are available. if MessagingMenu: self.init_messaging_menu() def init_messaging_menu(self): self.messaging = MessagingMenu.App(desktop_id='gwibber.desktop') self.messaging.register() def update_unread_count(self, count): """Update the unread count. If zero, make it invisible.""" if not self.messaging: return if self.messaging.has_source('unread') and count > 0: self.messaging.set_source_count('unread', count) elif count > 0: self.messaging.append_source_with_count( 'unread', None, 'Unread', count) elif self.messaging.has_source('unread') and count < 1: self.messaging.remove_source('unread') friends-0.2.0+14.04.20140217.1/friends/utils/shorteners.py0000644000015201777760000000605412300444435023243 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Look up a URL shortener by name.""" __all__ = [ 'Short', ] import re from urllib.parse import quote from friends.utils.base import LINKIFY_REGEX as replace_urls from friends.utils.http import Downloader # These strings define the shortening services. If you want to add a # new shortener to this list, the shortening service must take the URL # as a parameter, and return the plaintext URL as the result. No JSON # or XML parsing is supported. The strings below must contain exactly # one instance of '{}' to represent where the long URL goes in the # service. This is typically at the very end, but doesn't have to be. URLS = { 'is.gd': 'http://is.gd/api.php?longurl={}', 'linkee.com': 'http://api.linkee.com/1.0/shorten?format=text&input={}', 'ou.gd': 'http://ou.gd/api.php?format=simple&action=shorturl&url={}', 'tinyurl.com': 'http://tinyurl.com/api-create.php?url={}', 'durl.me': 'http://durl.me/api/Create.do?type=json&longurl={}', } class Short: """Each instance of this class represents a unique shortening service.""" def __init__(self, domain=None): """Determine which shortening service this instance will use.""" self.template = URLS.get(domain) self.domain = domain # Disable shortening if no shortener found. if None in (domain, self.template): self.make = lambda url: url return if "json" in self.template: self.make = self.json def make(self, url): """Shorten the URL by querying the shortening service.""" if Short.already(url): # Don't re-shorten an already-short URL. return url return Downloader( self.template.format(quote(url, safe=''))).get_string().strip() def sub(self, message): """Find *all* of the URLs in a string and shorten all of them.""" return replace_urls(lambda match: self.make(match.group(0)), message) def json(self, url): """Grab URLs swiftly with regex.""" # Avoids writing JSON code that is service-specific. find = re.compile('https?://{}[^\"]+'.format(self.domain)).findall for short in find(Short.make(self, url)): return short return url # Used for checking if URLs have already been shortened. already = re.compile(r'https?://({})/'.format('|'.join(URLS))).match friends-0.2.0+14.04.20140217.1/friends/utils/http.py0000644000015201777760000001737112300444435022032 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Convenient up- and down-loading.""" __all__ = [ 'Downloader', 'Uploader', 'BaseRateLimiter', ] import json import logging from contextlib import contextmanager from gi.repository import Gio, Soup, SoupGNOME from urllib.parse import urlencode from friends.errors import FriendsError log = logging.getLogger(__name__) # Global libsoup session instance. _soup = Soup.SessionSync() # Enable this for full requests and responses dumped to STDOUT. #_soup.add_feature(Soup.Logger.new(Soup.LoggerLogLevel.BODY, -1)) _soup.add_feature(SoupGNOME.ProxyResolverGNOME()) def _get_charset(message): """Extract charset from Content-Type header in a Soup Message.""" type_header = message.response_headers.get_content_type()[1] if not type_header: return None return type_header.get('charset') class BaseRateLimiter: """Base class for the rate limiting API. By default, this class does no rate limiting. Subclass from this and override the `wait()` and `update()` methods for protocol specific rate-limiting functionality. """ def wait(self, message): """Wait an appropriate amount of time before returning. Downloading is blocked until this method returns. This does not block the entire application since downloading always happens in a sub-thread. If no wait is necessary, return immediately. :param message: The constructed but unsent libSoup Message. :type message: Soup.Message """ pass def update(self, message): """Update any rate limiting values based on the service's response. :param message: The same libSoup Message complete with response headers. :type message: Soup.Message """ pass class HTTP: """Parent class for Uploader and Downloader.""" @contextmanager def _transfer(self): """Perform the download, as a context manager.""" message = self._build_request() if message is None: raise ValueError('Failed to build this HTTP request.') _soup.send_message(message) if message.status_code != 200: log.error('{}: {} {}'.format(self.url, message.status_code, message.reason_phrase)) yield message self._rate_limiter.update(message) def get_json(self): """Interpret and return the results as JSON data.""" with self._transfer() as message: payload = message.response_body.flatten().get_data() charset = _get_charset(message) if not payload: raise FriendsError('Got zero-length response from server.') if len(payload) < 4 and charset is None: charset = 'utf-8' # Safest assumption # RFC 4627 $3. JSON text SHALL be encoded in Unicode. The default # encoding is UTF-8. Since the first two characters of a JSON text # will always be ASCII characters [RFC0020], it is possible to # determine whether an octet stream is UTF-8, UTF-16 (BE or LE), or # UTF-32 (BE or LE) by looking at the pattern of nulls in the first # four octets. if charset is None: octet_0, octet_1, octet_2, octet_3 = payload[:4] if 0 not in (octet_0, octet_1, octet_2, octet_3): charset = 'utf-8' elif (octet_1 == octet_3 == 0) and octet_2 != 0: charset = 'utf-16le' elif (octet_0 == octet_2 == 0) and octet_1 != 0: charset = 'utf-16be' elif (octet_1 == octet_2 == octet_3 == 0): charset = 'utf-32le' elif (octet_0 == octet_1 == octet_2 == 0): charset = 'utf-32be' return json.loads(payload.decode(charset)) def get_bytes(self): """Return the results as a bytes object.""" with self._transfer() as message: return message.response_body.flatten().get_data() def get_string(self): """Return the results as a string, decoded as per the response.""" with self._transfer() as message: payload = message.response_body.flatten().get_data() charset = _get_charset(message) if charset: return payload.decode(charset) else: return payload.decode() class Downloader(HTTP): """Convenient downloading wrapper.""" def __init__(self, url, params=None, method='GET', headers=None, rate_limiter=None): self.url = url self.method = method self.params = params or {} self.headers = headers or {} self._rate_limiter = rate_limiter or BaseRateLimiter() def _build_request(self): """Return a libsoup message, with all the right headers. :return: A constructed but unsent libsoup message. :rtype: Soup.Message """ data = None url = self.url # urlencode() does not have an option to use quote() instead of # quote_plus(), but Twitter requires percent-encoded spaces, and # this is harmless to any other protocol. params = urlencode(self.params).replace('+', '%20') if params: if self.method == 'GET': # Do a GET with an encoded query string. url = '{}?{}'.format(self.url, params) else: data = params message = Soup.Message.new(self.method, url) for header, value in self.headers.items(): message.request_headers.append(header, value) if data is not None: message.set_request( 'application/x-www-form-urlencoded; charset=utf-8', Soup.MemoryUse.COPY, data, len(data)) # Possibly do some rate limiting. self._rate_limiter.wait(message) return message class Uploader(HTTP): """Convenient uploading wrapper.""" def __init__(self, url, filename, desc='', picture_key=None, desc_key=None, headers=None, **kwargs): self.url = url self.filename = filename self.description = desc self.picture_key = picture_key self.description_key = desc_key self.headers = headers or {} self.extra_keys = kwargs self._rate_limiter = BaseRateLimiter() def _build_request(self): gfile = Gio.File.new_for_uri(self.filename) data = gfile.load_contents(None)[1] body = Soup.Buffer.new([byte for byte in data]) multipart = Soup.Multipart.new('multipart/form-data') for key, value in self.extra_keys.items(): multipart.append_form_string(key, value) if self.description and self.description_key: multipart.append_form_string(self.description_key, self.description) multipart.append_form_file( self.picture_key, self.filename, 'application/octet-stream', body) message = Soup.form_request_new_from_multipart(self.url, multipart) for header, value in self.headers.items(): message.request_headers.append(header, value) return message friends-0.2.0+14.04.20140217.1/friends/utils/cache.py0000644000015201777760000000517112300444435022111 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Persistent data store using JSON.""" __all__ = [ 'JsonCache', ] import os import json import logging from gi.repository import GLib from friends.errors import ignored log = logging.getLogger(__name__) class JsonCache(dict): """Simple dict that is backed by JSON data in a text file. Serializes itself to disk with every call to __setitem__, so it's not well suited for large, frequently-changing dicts. But useful for small dicts that change infrequently. Typically I expect this to be used for dicts that only change once or twice during the lifetime of the program, but needs to remember its state between invocations. If, for some unforeseen reason, you do need to dump a lot of data into this dict without triggering a ton of disk writes, it is possible to call dict.update with all the new values, followed by a single call to .write(). Keep in mind that the more data you store in this dict, the slower read/writes will be with each invocation. At the time of this writing, there are only three instances used throughout Friends, and they are all under 200 bytes. """ # Where to store all the json files. _root = os.path.join(GLib.get_user_cache_dir(), 'friends', '{}.json') def __init__(self, name): dict.__init__(self) self._path = self._root.format(name) try: with open(self._path, 'r') as cache: self.update(json.loads(cache.read())) except (FileNotFoundError, ValueError, UnicodeDecodeError): # This writes '{}' to self._filename on first run. self.write() def write(self): """Write our dict contents to disk as a JSON string.""" with open(self._path, 'w') as cache: cache.write(json.dumps(self)) def __setitem__(self, key, value): """Write to disk every time dict is updated.""" dict.__setitem__(self, key, value) self.write() friends-0.2.0+14.04.20140217.1/friends/utils/authentication.py0000644000015201777760000000647512300444435024075 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Authentication through the single-sign-on service.""" __all__ = [ 'Authentication', ] import logging import time from gi.repository import Accounts, Signon from friends.errors import AuthorizationError log = logging.getLogger(__name__) LOGIN_TIMEOUT = 30 # Currently this is measured in half-seconds. # Yes, this is not the most logical place to instantiate this, but I # couldn't do it in account.py due to cyclical import dependencies. manager = Accounts.Manager.new_for_service_type('microblogging') class Authentication: def __init__(self, account_id): self.account_id = account_id account = manager.get_account(account_id) for service in account.list_services(): self.auth = Accounts.AccountService.new( account, service).get_auth_data() break else: raise AuthorizationError( account_id, 'No AgService found, is your UOA plugin written correctly?') self._reply = None self._error = None def login(self): auth = self.auth self.auth_session = Signon.AuthSession.new( auth.get_credentials_id(), auth.get_method()) self.auth_session.process( auth.get_parameters(), auth.get_mechanism(), self._login_cb, None) timeout = LOGIN_TIMEOUT while self._reply is None and timeout > 0: # We're building a synchronous API on top of an inherently # async library, so we need to block this thread until the # callback gets called to give us the response to return. time.sleep(0.5) timeout -= 1 if self._error is not None: exception = AuthorizationError(self.account_id, self._error.message) # Mardy says this error can happen during normal operation. if exception.message.endswith('userActionFinished error: 10'): log.error(str(exception)) else: raise exception if self._reply is None: raise AuthorizationError(self.account_id, 'Login timed out.') if 'AccessToken' not in self._reply: raise AuthorizationError( self.account_id, 'No AccessToken found: {!r}'.format(self._reply)) return self._reply def _login_cb(self, session, reply, error, user_data): # Don't raise Exceptions here because this callback runs in # MainThread, not the thread you expect it to. if error: self._error = error self._reply = reply log.debug('_login_cb completed') friends-0.2.0+14.04.20140217.1/friends/utils/account.py0000644000015201777760000000736712300444435022513 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The libaccounts account service wrapper.""" __all__ = [ 'Account', 'find_accounts', ] import logging from gi.repository import Accounts from threading import Lock from friends.errors import UnsupportedProtocolError from friends.utils.manager import protocol_manager from friends.utils.authentication import manager log = logging.getLogger(__name__) def _find_accounts_uoa(): """Consult Ubuntu Online Accounts for the accounts we have.""" accounts = {} for service in manager.get_enabled_account_services(): try: account = Account(service) except UnsupportedProtocolError as error: log.info(error) else: accounts[account.id] = account log.info('Accounts found: {}'.format(len(accounts))) return accounts def find_accounts(): # TODO: Implement GOA support, then fill out this method with some # logic for determining whether to use UOA or GOA. return _find_accounts_uoa() class Account: """A thin wrapper around libaccounts API.""" # Properties to pull out of the libaccounts iterator. See the discussion # below for more details. _LIBACCOUNTS_PROPERTIES = ( 'send_enabled', ) # Defaults for the known and useful attributes. consumer_secret = None consumer_key = None access_token = None secret_token = None send_enabled = None user_name = None user_id = None auth = None id = None def __init__(self, account_service): self.auth = account_service.get_auth_data() if self.auth is not None: params = self.auth.get_parameters() self.consumer_key = (params.get('ConsumerKey') or params.get('ClientId')) self.consumer_secret = (params.get('ConsumerSecret') or params.get('ClientSecret')) else: raise UnsupportedProtocolError( 'This AgAccountService is missing AgAuthData!') # The provider in libaccounts should match the name of our protocol. account = account_service.get_account() self.id = account.id protocol_name = account.get_provider_name() protocol_class = protocol_manager.protocols.get(protocol_name) if protocol_class is None: raise UnsupportedProtocolError(protocol_name) self.protocol = protocol_class(self) # Connect responders to changes in the account information. account_service.connect('changed', self._on_account_changed, account) self._on_account_changed(account_service, account) # This is used to prevent multiple simultaneous login attempts. self.login_lock = Lock() def _on_account_changed(self, account_service, account): settings = account.get_settings_dict('friends/') for (key, value) in settings.items(): if key in Account._LIBACCOUNTS_PROPERTIES: log.debug('{} ({}) got {}: {}'.format( self.protocol._Name, self.id, key, value)) setattr(self, key, value) friends-0.2.0+14.04.20140217.1/friends/utils/notify.py0000644000015201777760000000410512300444435022352 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Notification logic. If libnotify is missing, then notify() is a do-nothing stub that silently ignores all calls. """ __all__ = [ 'notify', ] from gi.repository import GObject, GdkPixbuf from friends.utils.avatar import Avatar from friends.errors import ignored # This gets conditionally imported at the end of this file, which # allows for easier overriding of the following function definition. Notify = None def notify(title, message, icon_uri='', pixbuf=None): """Display the message along with sender's name and avatar.""" if not (title and message): return notification = Notify.Notification.new( title, message, 'friends') with ignored(GObject.GError): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( Avatar.get_image(icon_uri), 48, 48) if pixbuf is not None: notification.set_icon_from_pixbuf(pixbuf) if _notify_can_append: notification.set_hint_string('x-canonical-append', 'allowed') with ignored(GObject.GError): # Most likely we've spammed more than 50 notificatons, # not much we can do about that. notification.show() # Optional dependency on Notify library. try: from gi.repository import Notify except ImportError: notify = lambda *ignore, **kwignore: None else: Notify.init('friends') _notify_can_append = 'x-canonical-append' in Notify.get_server_caps() friends-0.2.0+14.04.20140217.1/friends/utils/logging.py0000644000015201777760000000530012300444435022466 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Logging utilities.""" import os import logging import logging.handlers import oauthlib.oauth1 from gi.repository import GLib from friends.errors import ignored # Set a global default of no logging. This is a workaround for a bug # where we were getting duplicated log records. logging.basicConfig(filename='/dev/null', level=100) # Disable logging in oauthlib because it is very verbose. oauthlib.oauth1.rfc5849.logging.debug = lambda *ignore: None LOG_FILENAME = os.path.join( os.path.realpath(os.path.abspath(GLib.get_user_cache_dir())), 'friends', 'friends.log') LOG_FORMAT = '{levelname:5} {threadName:10} {asctime} {name:18} {message}' CSL_FORMAT = LOG_FORMAT.replace(' {asctime}', '') def initialize(console=False, debug=False, filename=None): """Initialize the Friends service logger. :param console: Add a console logger. :type console: bool :param debug: Set the log level to DEBUG instead of INFO. :type debug: bool :param filename: Alternate file to log messages to. :type filename: string """ # Start by ensuring that the directory containing the log file exists. if filename is None: filename = LOG_FILENAME with ignored(FileExistsError): os.makedirs(os.path.dirname(filename)) # Install a rotating log file handler. XXX There should be a # configuration file rather than hard-coded values. text_handler = logging.handlers.RotatingFileHandler( filename, maxBytes=20971520, backupCount=5) # Use str.format() style format strings. text_formatter = logging.Formatter(LOG_FORMAT, style='{') text_handler.setFormatter(text_formatter) log = logging.getLogger() log.addHandler(text_handler) if debug: log.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) if console: console_handler = logging.StreamHandler() console_formatter = logging.Formatter(CSL_FORMAT, style='{') console_handler.setFormatter(console_formatter) log.addHandler(console_handler) friends-0.2.0+14.04.20140217.1/friends/utils/install.py0000644000015201777760000000554212300444435022516 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """DBus service file installation helper. This is used in setup.py to install the real service files. This module must be cleanly importable with no requirements on non-stdlib available packages (pkg_resources is okay though). """ __all__ = [ 'install_service_files', ] import os import sys from distutils.cmd import Command from pkg_resources import resource_listdir, resource_string COMMASPACE = ', ' def _do_basic_install(destdir, service_files, args): bindir = os.path.dirname(sys.executable) for filename in service_files: template = resource_string('friends.service.templates', filename) template = template.decode('utf-8') contents = template.format(BINDIR=bindir, ARGS=args) target_filename, ext = os.path.splitext(filename) assert ext == '.in' service_path = os.path.join(destdir, target_filename) with open(service_path, 'w', encoding='utf-8') as fp: fp.write(contents) class install_service_files(Command): description = 'Install the DBus service files' command_consumes_arguments = True user_options = [ ('root', 'd', 'Root directory containing share/dbus-1/services/'), ] def initialize_options(self): # distutils is insane. We must set self.root even though the # arguments will get passed into .run() via self.args. self.args = None self.root = None def finalize_options(self): pass def run(self): if len(self.args) != 1: raise RuntimeError( 'Bad arguments: {}'.format(COMMASPACE.join(self.args))) root_dir = self.args[0] # Make sure the destination directory exists. Generally it will when # installed in a real system, but won't when installed in a virtualenv # (what about a package build?). destdir = os.path.join(root_dir, 'share', 'dbus-1', 'services') os.makedirs(destdir, exist_ok=True) service_files = [ filename for filename in resource_listdir('friends.service', 'templates') if filename.endswith('.service.in') ] _do_basic_install(destdir, service_files, '-o') friends-0.2.0+14.04.20140217.1/friends/utils/manager.py0000644000015201777760000000400412300444435022452 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Protocol base class and manager.""" __all__ = [ 'ProtocolManager', 'protocol_manager', ] import os import importlib from pkg_resources import resource_listdir from friends.utils.base import Base class ProtocolManager: """Discover all protocol classes.""" def __init__(self): self.protocols = dict((cls.__name__.lower(), cls) for cls in self._protocol_classes) @property def _protocol_classes(self): """Search for and return all protocol classes.""" for filename in resource_listdir('friends', 'protocols'): basename, extension = os.path.splitext(filename) if extension != '.py': continue module_path = 'friends.protocols.' + basename module = importlib.import_module(module_path) # Scan all the objects in the module's __all__ and add any which # are subclasses of the base protocol class. Essentially skip any # modules that don't have an __all__ (e.g. the __init__.py). # However, the module better not lie about its __all__ members. for name in getattr(module, '__all__', []): obj = getattr(module, name) if issubclass(obj, Base): yield obj protocol_manager = ProtocolManager() friends-0.2.0+14.04.20140217.1/friends/utils/avatar.py0000644000015201777760000000436112300444435022324 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Utils for downloading, sizing, and caching of avatar images.""" __all__ = [ 'Avatar', ] import os import logging from gi.repository import Gio, GLib, GdkPixbuf from tempfile import gettempdir from hashlib import sha1 from friends.utils.http import Downloader from friends.errors import ignored CACHE_DIR = os.path.join(gettempdir(), 'friends-avatars') with ignored(FileExistsError): os.makedirs(CACHE_DIR) log = logging.getLogger(__name__) class Avatar: @staticmethod def get_path(url): return os.path.join(CACHE_DIR, sha1(url.encode('utf-8')).hexdigest()) @staticmethod def get_image(url): if not url: return url local_path = Avatar.get_path(url) size = 0 with ignored(FileNotFoundError): size = os.stat(local_path).st_size if size == 0: log.debug('Getting: {}'.format(url)) image_data = Downloader(url).get_bytes() # Save original size at canonical URI with open(local_path, 'wb') as fd: fd.write(image_data) # Append '.100px' to filename and scale image there. input_stream = Gio.MemoryInputStream.new_from_data( image_data, None) try: pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale( input_stream, 100, 100, True, None) pixbuf.savev(local_path + '.100px', 'png', [], []) except GLib.GError: log.error('Failed to scale image: {}'.format(url)) return local_path friends-0.2.0+14.04.20140217.1/friends/main.py0000644000015201777760000001240212300444435020625 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Main friends-dispatcher module. This gets turned into a script by `python3 setup.py install`. """ __all__ = [ 'main', ] import sys import dbus import logging # Set up the DBus main loop. from dbus.mainloop.glib import DBusGMainLoop from gi.repository import GLib, Gio DBusGMainLoop(set_as_default=True) loop = GLib.MainLoop() from friends.errors import ignored # Short-circuit everything else if we are going to enter test-mode. from friends.utils.options import Options args = Options().parser.parse_args() if args.test: from friends.service.mock_service import Dispatcher from friends.tests.mocks import populate_fake_data populate_fake_data() Dispatcher() with ignored(KeyboardInterrupt): loop.run() sys.exit(0) # Continue with normal loading... from friends.service.dispatcher import Dispatcher, DBUS_INTERFACE from friends.utils.base import Base, initialize_caches, _publish_lock from friends.utils.model import Model, prune_model from friends.utils.logging import initialize # Optional performance profiling module. yappi = None # Logger must be initialized before it can be used. log = None # We need to acquire the publish lock so that the dispatcher # doesn't try to publish rows into an uninitialized model... # basically this prevents duplicates from showing up. # We release this lock later, once the model is synchronized. _publish_lock.acquire() def main(): global log global yappi if args.list_protocols: from friends.utils.manager import protocol_manager for name in sorted(protocol_manager.protocols): cls = protocol_manager.protocols[name] package, dot, class_name = cls.__name__.rpartition('.') print(class_name) return # Disallow multiple instances of friends-dispatcher bus = dbus.SessionBus() obj = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus') iface = dbus.Interface(obj, 'org.freedesktop.DBus') if DBUS_INTERFACE in iface.ListNames(): sys.exit('friends-dispatcher is already running! Abort!') if args.performance: with ignored(ImportError): import yappi yappi.start() # Initialize the logging subsystem. gsettings = Gio.Settings.new('com.canonical.friends') initialize(console=args.console, debug=args.debug or gsettings.get_boolean('debug')) log = logging.getLogger(__name__) log.info('Friends backend dispatcher starting') # ensure friends-service is available to provide the Dee.SharedModel server = bus.get_object( 'com.canonical.Friends.Service', '/com/canonical/friends/Service') # Determine which messages to notify for. notify_level = gsettings.get_string('notifications') if notify_level == 'all': Base._do_notify = lambda protocol, stream: True elif notify_level == 'none': Base._do_notify = lambda protocol, stream: False else: Base._do_notify = lambda protocol, stream: stream in ( 'mentions', 'private', ) Dispatcher(gsettings, loop) # Don't initialize caches until the model is synchronized Model.connect('notify::synchronized', setup) with ignored(KeyboardInterrupt): log.info('Starting friends-dispatcher main loop') loop.run() log.info('Stopped friends-dispatcher main loop') # This bit doesn't run until after the mainloop exits. if args.performance and yappi is not None: yappi.print_stats(sys.stdout, yappi.SORTTYPE_TTOT) def setup(model, param): """Continue friends-dispatcher init after the DeeModel has synced.""" # mhr3 says that we should not let a Dee.SharedModel exceed 8mb in # size, because anything larger will have problems being transmitted # over DBus. I have conservatively calculated our average row length # to be 500 bytes, which means that we shouldn't let our model exceed # approximately 16,000 rows. However, that seems like a lot to me, so # I'm going to set it to 2,000 for now and we can tweak this later if # necessary. Do you really need more than 2,000 tweets in memory at # once? What are you doing with all these tweets? prune_model(2000) # This builds two different indexes of our persisted Dee.Model # data for the purposes of faster duplicate checks. initialize_caches() # Exception indicates that lock was already released, which is harmless. with ignored(RuntimeError): # Allow publishing. _publish_lock.release() if __name__ == '__main__': # Use this with `python3 -m friends.main` main() friends-0.2.0+14.04.20140217.1/friends/protocols/0000755000015201777760000000000012300444701021350 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/protocols/__init__.py0000644000015201777760000000000012300444435023453 0ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/friends/protocols/instagram.py0000644000015201777760000001562112300444435023720 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2013 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The Instagram protocol plugin.""" __all__ = [ 'Instagram', ] import logging from friends.utils.base import Base, feature from friends.utils.http import Downloader from friends.utils.time import parsetime, iso8601utc from friends.errors import FriendsError log = logging.getLogger(__name__) class Instagram(Base): _api_base = 'https://api.instagram.com/v1/{endpoint}?access_token={token}' def _whoami(self, authdata): """Identify the authenticating user.""" url = self._api_base.format( endpoint='users/self', token=self._get_access_token()) result = Downloader(url).get_json() self._account.user_id = result.get('data').get('id') self._account.user_name = result.get('data').get('username') def _publish_entry(self, entry, stream='messages'): """Publish a single update into the Dee.SharedModel.""" message_id = entry.get('id') if message_id is None: # We can't do much with this entry. return person = entry.get('user') nick = person.get('username') name = person.get('full_name') person_id = person.get('id') message= '%s shared a picture on Instagram.' % nick person_icon = person.get('profile_picture') person_url = 'http://instagram.com/' + nick picture = entry.get('images').get('thumbnail').get('url') if entry.get('caption'): desc = entry.get('caption').get('text', '') else: desc = '' url = entry.get('link') timestamp = entry.get('created_time') if timestamp is not None: timestamp = iso8601utc(parsetime(timestamp)) likes = entry.get('likes').get('count') liked = entry.get('user_has_liked') location = entry.get('location', {}) if location: latitude = location.get('latitude', '') longitude = location.get('longitude', '') else: latitude = 0 longitude = 0 args = dict( message_id=message_id, message=message, stream=stream, likes=likes, sender_id=person_id, sender=name, sender_nick=nick, url=person_url, icon_uri=person_icon, link_url=url, link_picture=picture, link_desc=desc, timestamp=timestamp, liked=liked, latitude=latitude, longitude=longitude ) self._publish(**args) # If there are any replies, publish them as well. parent_id = message_id for comment in entry.get('comments', {}).get('data', []): if comment: self._publish_comment( comment, stream='reply_to/{}'.format(parent_id)) return args['url'] def _publish_comment(self, comment, stream): """Publish a single comment into the Dee.SharedModel.""" message_id = comment.get('id') if message_id is None: return message = comment.get('text', '') person = comment.get('from', {}) sender_nick = person.get('username') timestamp = comment.get('created_time') if timestamp is not None: timestamp = iso8601utc(parsetime(timestamp)) icon_uri = person.get('profile_picture') sender_id = person.get('id') sender = person.get('full_name') args = dict( stream=stream, message_id=message_id, message=message, timestamp=timestamp, sender_nick=sender_nick, icon_uri=icon_uri, sender_id=sender_id, sender=sender, ) self._publish(**args) @feature def home(self): """Gather and publish public timeline messages.""" url = self._api_base.format( endpoint='users/self/feed', token=self._get_access_token()) result = Downloader(url).get_json() values = result.get('data', {}) for update in values: self._publish_entry(update) @feature def receive(self): """Gather and publish all incoming messages.""" self.home() return self._get_n_rows() def _send(self, obj_id, message, endpoint, stream='messages'): """Used for posting a message or comment.""" token = self._get_access_token() url = self._api_base.format(endpoint=endpoint, token=token) result = Downloader( url, method='POST', params=dict(access_token=token, text=message)).get_json() new_id = result.get('id') if new_id is None: raise FriendsError( 'Failed sending to Instagram: {!r}'.format(result)) url = self._api_base.format(endpoint=endpoint, token=token) comment = Downloader(url, params=dict(access_token=token)).get_json() return self._publish_entry(entry=comment, stream=stream) @feature def send_thread(self, obj_id, message): """Write a comment on some existing picture.""" return self._send( obj_id, message, 'media/{}/comments'.format(obj_id), stream='reply_to/{}'.format(obj_id)) def _like(self, obj_id, endpoint, method): """Used for liking or unliking an object.""" token = self._get_access_token() url = self._api_base.format(endpoint=endpoint, token=token) if not Downloader( url, method=method, params=dict(access_token=token)).get_json(): raise FriendsError( 'Failed to {} like {} on Instagram'.format( method, obj_id)) @feature def like(self, obj_id): endpoint = 'media/{}/likes'.format(obj_id) self._like(obj_id, endpoint, 'POST') self._inc_cell(obj_id, 'likes') self._set_cell(obj_id, 'liked', True) return obj_id @feature def unlike(self, obj_id): endpoint = 'media/{}/likes'.format(obj_id) self._like(obj_id, endpoint, 'DELETE') self._dec_cell(obj_id, 'likes') self._set_cell(obj_id, 'liked', False) return obj_id friends-0.2.0+14.04.20140217.1/friends/protocols/identica.py0000644000015201777760000000472612300444435023517 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The Identi.ca protocol plugin.""" __all__ = [ 'Identica', ] from friends.protocols.twitter import Twitter class Identica(Twitter): _api_base = 'http://identi.ca/api/{endpoint}.json' _timeline = _api_base.format(endpoint='statuses/{}_timeline') _user_timeline = _timeline.format('user') + '?screen_name={}' _mentions_timeline = _api_base.format(endpoint='statuses/mentions') _destroy = _api_base.format(endpoint='statuses/destroy/{}') _retweet = _api_base.format(endpoint='statuses/retweet/{}') _search = _api_base.format(endpoint='search') _search_result_key = 'results' _favorite = _api_base.format(endpoint='favorites/create/{}') _del_favorite = _api_base.format(endpoint='favorites/destroy/{}') _user_home = 'https://identi.ca/{user_id}' _tweet_permalink = 'http://identi.ca/notice/{tweet_id}' def _whoami(self, authdata): """Identify the authenticating user.""" self._account.secret_token = authdata.get('TokenSecret') url = self._api_base.format(endpoint='users/show') result = self._get_url(url) self._account.user_id = result.get('id') self._account.user_name = result.get('screen_name') def list(self, list_id): """Identi.ca does not have this feature.""" raise NotImplementedError def lists(self): """Identi.ca does not have this feature.""" raise NotImplementedError def tag(self, tweet_id): """Searching for hashtags gives non-hashtags in the results. Eg, whereas twitter.tag('bike') only gives you tweets containing '#bike', identica.tag('bike') gives results containing both 'bike' and '#bike', which is essentially useless. Just use search() instead. """ raise NotImplementedError friends-0.2.0+14.04.20140217.1/friends/protocols/facebook.py0000644000015201777760000003260112300444435023501 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The Facebook protocol plugin.""" __all__ = [ 'Facebook', ] import time import logging from friends.utils.base import Base, feature from friends.utils.cache import JsonCache from friends.utils.http import Downloader, Uploader from friends.utils.time import parsetime, iso8601utc from friends.errors import FriendsError # 'id' can be the id of *any* Facebook object # https://developers.facebook.com/docs/reference/api/ URL_BASE = 'https://{subdomain}.facebook.com/' PERMALINK = URL_BASE.format(subdomain='www') + '{id}' API_BASE = URL_BASE.format(subdomain='graph') + '{id}' ME_URL = API_BASE.format(id='me') STORY_PERMALINK = PERMALINK + '/posts/{post_id}' TEN_DAYS = 864000 # seconds log = logging.getLogger(__name__) class Facebook(Base): def __init__(self, account): super().__init__(account) self._timestamps = PostIdCache(self._name + '_ids') def _whoami(self, authdata): """Identify the authenticating user.""" me_data = Downloader( ME_URL, dict(access_token=self._account.access_token)).get_json() self._account.user_id = me_data.get('id') self._account.user_name = me_data.get('name') def _publish_entry(self, entry, stream='messages'): message_id = entry.get('id') message_type = entry.get('type') if "reply" in stream: message_type = "reply" if None in (message_id, message_type): # We can't do much with this entry. return if 'to' in entry: # Somebody posted on somebodies wall # This cannot be displayed properly in friends so ignore return place = entry.get('place', {}) location = place.get('location', {}) link_pic = entry.get('picture', '') # Use objectID to get a highres version of the picture # Does not seem to work for links object_id = entry.get('object_id') if object_id and ('photo' in message_type): link_pic = "http://graph.facebook.com/" + object_id + "/picture?type=normal" args = dict( message_id=message_id, stream='images' if ('photo' in message_type) else stream, message=entry.get('message', '') or entry.get('story', ''), icon_uri=entry.get('icon', ''), link_picture=link_pic, link_name=entry.get('name', ''), link_url=entry.get('link', ''), link_desc=entry.get('description', ''), link_caption=entry.get('caption', ''), location=place.get('name', ''), latitude=location.get('latitude', 0.0), longitude=location.get('longitude', 0.0), ) # Posts gives us a likes dict, while replies give us an int. likes = entry.get('likes', 0) if isinstance(likes, dict): likes = likes.get('count', 0) args['likes'] = likes # Fix for LP:1185684 - JPM post_id = message_id.split('_')[1] if '_' in message_id else message_id from_record = entry.get('from') if from_record is not None: args['sender'] = from_record.get('name', '') args['sender_id'] = sender_id = from_record.get('id', '') args['url'] = STORY_PERMALINK.format( id=sender_id, post_id=post_id) args['icon_uri'] = (API_BASE.format(id=sender_id) + '/picture?width=840&height=840') args['sender_nick'] = from_record.get('name', '') args['from_me'] = (sender_id == self._account.user_id) # Normalize the timestamp. timestamp = entry.get('updated_time', entry.get('created_time')) if timestamp is not None: timestamp = args['timestamp'] = iso8601utc(parsetime(timestamp)) # We need to record timestamps for use with since=. Note that # _timestamps is a special dict subclass that only accepts # timestamps that are larger than the existing value, so at any # given time it will map the stream to the most # recent timestamp we've seen for that stream. self._timestamps[stream] = timestamp # Publish this message into the SharedModel. self._publish(**args) # If there are any replies, publish them as well. for comment in entry.get('comments', {}).get('data', []): if comment: self._publish_entry( stream='reply_to/{}'.format(message_id), entry=comment) return args['url'] def _follow_pagination(self, url, params, limit=None): """Follow Facebook's pagination until we hit the limit.""" limit = limit or self._DOWNLOAD_LIMIT entries = [] while True: response = Downloader(url, params).get_json() if self._is_error(response): break data = response.get('data') if data is None: break entries.extend(data) if len(entries) >= limit: break # We haven't gotten the requested number of entries. Follow the # next page if there is one to try to get more. pages = response.get('paging') if pages is None: break # The 'next' key has the full link to follow; no additional # parameters are needed. Specifically, this link will already # include the access_token, and any since/limit values. url = pages.get('next') params = None if url is None: break # We've gotten everything Facebook is going to give us. return entries def _get(self, url, stream): """Retrieve a list of Facebook objects. A maximum of 50 objects are requested. """ access_token = self._get_access_token() since = self._timestamps.get( stream, iso8601utc(int(time.time()) - TEN_DAYS)) entries = [] params = dict(access_token=access_token, since=since, limit=self._DOWNLOAD_LIMIT) entries = self._follow_pagination(url, params) # https://developers.facebook.com/docs/reference/api/post/ for entry in entries: self._publish_entry(entry, stream=stream) @feature def home(self): """Gather and publish public timeline messages.""" self._get(ME_URL + '/home', 'messages') return self._get_n_rows() @feature def wall(self): """Gather and publish messages written on user's wall.""" self._get(ME_URL + '/feed', 'mentions') return self._get_n_rows() @feature def receive(self): self.wall() self.home() return self._get_n_rows() @feature def search(self, query): """Search for up to 50 items matching query.""" access_token = self._get_access_token() entries = [] url = API_BASE.format(id='search') params = dict( access_token=access_token, q=query) entries = self._follow_pagination(url, params) # https://developers.facebook.com/docs/reference/api/post/ for entry in entries: self._publish_entry(entry, 'search/{}'.format(query)) return len(entries) def _like(self, obj_id, method): url = API_BASE.format(id=obj_id) + '/likes' token = self._get_access_token() if not Downloader(url, method=method, params=dict(access_token=token)).get_json(): raise FriendsError('Failed to {} like {} on Facebook'.format( method, obj_id)) @feature def like(self, obj_id): """Like any arbitrary object on Facebook. This includes messages, statuses, wall posts, events, etc. """ self._like(obj_id, 'POST') self._inc_cell(obj_id, 'likes') self._set_cell(obj_id, 'liked', True) return obj_id @feature def unlike(self, obj_id): """Unlike any arbitrary object on Facebook. This includes messages, statuses, wall posts, events, etc. """ self._like(obj_id, 'DELETE') self._dec_cell(obj_id, 'likes') self._set_cell(obj_id, 'liked', False) return obj_id def _send(self, obj_id, message, endpoint, stream='messages'): url = API_BASE.format(id=obj_id) + endpoint token = self._get_access_token() result = Downloader( url, method='POST', params=dict(access_token=token, message=message)).get_json() new_id = result.get('id') if new_id is None: raise FriendsError('Failed sending to Facebook: {!r}'.format(result)) url = API_BASE.format(id=new_id) entry = Downloader(url, params=dict(access_token=token)).get_json() return self._publish_entry( stream=stream, entry=entry) @feature def send(self, message, obj_id='me'): """Write a message on somebody or something's wall. If you don't specify an obj_id, it defaults to your wall. obj_id can be any type of Facebook object that has a wall, be it a user, an app, a company, an event, etc. """ return self._send(obj_id, message, '/feed') @feature def send_thread(self, obj_id, message): """Write a comment on some existing status message. obj_id can be the id of any Facebook object that supports being commented on, which will generally be Posts. """ return self._send(obj_id, message, '/comments', stream='reply_to/{}'.format(obj_id)) @feature def delete(self, obj_id): """Delete any Facebook object that you are the owner of.""" url = API_BASE.format(id=obj_id) token = self._get_access_token() if not Downloader(url, method='DELETE', params=dict(access_token=token)).get_json(): raise FriendsError('Failed to delete {} on Facebook'.format(obj_id)) else: self._unpublish(obj_id) return obj_id @feature def upload(self, picture_uri, description=''): # http://developers.facebook.com/docs/reference/api/photo/ """Upload local or remote image or video to album.""" url = '{}/photos?access_token={}'.format( ME_URL, self._get_access_token()) response = Uploader( url, picture_uri, description, picture_key='source', desc_key='message').get_json() self._is_error(response) post_id = response.get('post_id') if post_id is not None: destination_url = PERMALINK.format(id=post_id) self._publish( from_me=True, stream='images', message_id=post_id, message=description, sender=self._account.user_name, sender_id=self._account.user_id, sender_nick=self._account.user_name, timestamp=iso8601utc(int(time.time())), url=destination_url, icon_uri=(API_BASE.format(id=self._account.user_id) + '/picture?type=large')) return destination_url else: raise FriendsError(str(response)) @feature def contacts(self): access_token=self._get_access_token() contacts = self._follow_pagination( url=ME_URL + '/friends', params=dict(access_token=access_token, limit=1000), limit=1000) log.debug('Found {} contacts'.format(len(contacts))) for contact in contacts: contact_id = contact.get('id') if not self._previously_stored_contact(contact_id): full_contact = Downloader( url=API_BASE.format(id=contact_id), params=dict(access_token=access_token)).get_json() self._push_to_eds( uid=contact_id, name=full_contact.get('name'), nick=full_contact.get('username'), link=full_contact.get('link'), gender=full_contact.get('gender'), jabber='-{}@chat.facebook.com'.format(contact_id)) return len(contacts) class PostIdCache(JsonCache): """Persist most-recent timestamps as JSON.""" def __setitem__(self, key, value): if key.find('/') >= 0: # Don't flood the cache with irrelevant "reply_to/..." and # "search/..." streams, we only need the main streams. return # Thank SCIENCE for lexically-sortable timestamp strings! if value > self.get(key, ''): JsonCache.__setitem__(self, key, value) friends-0.2.0+14.04.20140217.1/friends/protocols/linkedin.py0000644000015201777760000001035212300444435023524 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2013 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The LinkedIn protocol plugin.""" __all__ = [ 'LinkedIn', ] import logging from friends.utils.base import Base, feature from friends.utils.http import Downloader from friends.utils.time import iso8601utc log = logging.getLogger(__name__) def make_fullname(firstName=None, lastName=None, **ignored): """Converts dict(firstName='Bob', lastName='Loblaw') into 'Bob Loblaw'.""" return ' '.join(name for name in (firstName, lastName) if name) class LinkedIn(Base): _api_base = ('https://api.linkedin.com/v1/{endpoint}?format=json' + '&secure-urls=true&oauth2_access_token={token}') def _whoami(self, authdata): """Identify the authenticating user.""" # http://developer.linkedin.com/documents/profile-fields url = self._api_base.format( endpoint='people/~:(id,first-name,last-name)', token=self._get_access_token()) result = Downloader(url).get_json() self._account.user_id = result.get('id') self._account.user_name = make_fullname(**result) def _publish_entry(self, entry, stream='messages'): """Publish a single update into the Dee.SharedModel.""" message_id = entry.get('updateKey') content = entry.get('updateContent', {}) person = content.get('person', {}) name = make_fullname(**person) person_id = person.get('id', '') status = person.get('currentStatus') picture = person.get('pictureUrl', '') url = person.get('siteStandardProfileRequest', {}).get('url', '') timestamp = entry.get('timestamp', 0) # We need to divide by 1000 here, as LinkedIn's timestamps have # milliseconds. iso_time = iso8601utc(int(timestamp/1000)) likes = entry.get('numLikes', 0) if None in (message_id, status): # Something went wrong; just ignore this malformed message. return args = dict( message_id=message_id, stream=stream, message=status, likes=likes, sender_id=person_id, sender=name, icon_uri=picture, url=url, timestamp=iso_time ) self._publish(**args) @feature def home(self): """Gather and publish public timeline messages.""" url = self._api_base.format( endpoint='people/~/network/updates', token=self._get_access_token()) + '&type=STAT' result = Downloader(url).get_json() for update in result.get('values', []): self._publish_entry(update) return self._get_n_rows() @feature def receive(self): """Gather and publish all incoming messages.""" return self.home() @feature def contacts(self): """Retrieve a list of up to 500 LinkedIn connections.""" # http://developer.linkedin.com/documents/connections-api connections = Downloader( url=self._api_base.format( endpoint='people/~/connections', token=self._get_access_token()) ).get_json().get('values', []) for connection in connections: uid = connection.get('id', 'private') fullname = make_fullname(**connection) if uid != 'private' and not self._previously_stored_contact(uid): self._push_to_eds( uid=uid, name=fullname, link=connection.get( 'siteStandardProfileRequest', {}).get('url')) return len(connections) friends-0.2.0+14.04.20140217.1/friends/protocols/twitter.py0000644000015201777760000004421012300444435023431 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The Twitter protocol plugin.""" __all__ = [ 'RateLimiter', 'Twitter', ] import time import logging from urllib.parse import quote from friends.utils.base import Base, feature from friends.utils.cache import JsonCache from friends.utils.http import BaseRateLimiter, Downloader from friends.utils.time import parsetime, iso8601utc from friends.errors import FriendsError, ignored log = logging.getLogger(__name__) # https://dev.twitter.com/docs/api/1.1 class Twitter(Base): # Identi.ca's API mimicks Twitter's to such a high degree that it # is implemented just as a subclass of this, hence we need these # constants defined as instance attributes, so that the Identica # class can override them. If you make any changes to this class # you must confirm that your changes do not break Identi.ca! _api_base = 'https://api.twitter.com/1.1/{endpoint}.json' _timeline = _api_base.format(endpoint='statuses/{}_timeline') _user_timeline = _timeline.format('user') + '?screen_name={}' _mentions_timeline = _timeline.format('mentions') _lists = _api_base.format(endpoint='lists/statuses') + '?list_id={}' _destroy = _api_base.format(endpoint='statuses/destroy/{}') _retweet = _api_base.format(endpoint='statuses/retweet/{}') _search = _api_base.format(endpoint='search/tweets') _search_result_key = 'statuses' _favorite = _api_base.format(endpoint='favorites/create') _del_favorite = _api_base.format(endpoint='favorites/destroy') _user_home = 'https://twitter.com/{user_id}' _tweet_permalink = _user_home + '/status/{tweet_id}' def __init__(self, account): super().__init__(account) self._rate_limiter = RateLimiter() # Can be 'twitter_ids' or 'identica_ids' self._tweet_ids = TweetIdCache(self._name + '_ids') def _whoami(self, authdata): """Identify the authenticating user.""" self._account.secret_token = authdata.get('TokenSecret') self._account.user_id = authdata.get('UserId') self._account.user_name = authdata.get('ScreenName') def _get_url(self, url, data=None): """Access the Twitter API with correct OAuth signed headers.""" do_post = data is not None method = 'POST' if do_post else 'GET' headers = self._get_oauth_headers( method=method, url=url, data=data, ) response = Downloader( url, params=data, headers=headers, method=method, rate_limiter=self._rate_limiter).get_json() self._is_error(response) return response def _publish_tweet(self, tweet, stream='messages'): """Publish a single tweet into the Dee.SharedModel.""" tweet_id = tweet.get('id_str') or str(tweet.get('id', '')) if not tweet_id: log.info('Ignoring tweet with no id_str value') return # We need to record tweet_ids for use with since_id. Note that # _tweet_ids is a special dict subclass that only accepts # tweet_ids that are larger than the existing value, so at any # given time it will map the stream to the largest (most # recent) tweet_id we've seen for that stream. self._tweet_ids[stream] = tweet_id # 'user' for tweets, 'sender' for direct messages. user = tweet.get('user', {}) or tweet.get('sender', {}) screen_name = user.get('screen_name', '') avatar_url = (user.get('profile_image_url_https') or # Twitter, or user.get('profile_image_url') or # Identi.ca '') permalink = self._tweet_permalink.format( user_id=screen_name, tweet_id=tweet_id) # If this is an RT, we are more interested in the original tweet retweet = tweet.get('retweeted_status', {}) entities = retweet.get('entities', {}) or tweet.get('entities', {}) message = retweet.get('text', '') or tweet.get('text', '') picture_url = '' urls = {} for url in (entities.get('urls', []) + entities.get('media', []) + entities.get('user_mentions', []) + entities.get('hashtags', [])): begin, end = url.get('indices', (None, None)) #Drop invalid entities (just to be safe) if None not in (begin, end): urls[begin] = url for key, url in sorted(urls.items(), reverse=True): begin, end = url.get('indices', (None, None)) expanded_url = url.get('expanded_url') display_url = url.get('display_url') other_url = url.get('url') mention_name = url.get('screen_name') picture_url = url.get('media_url', picture_url) hashtag = url.get('text') content = None # Friends has no notion of display URLs, so this is handled at the protocol level if (other_url or expanded_url): content = self._linkify(expanded_url or other_url, display_url or other_url) # Linkify hashtags until supported by friends-app if hashtag: content = self._linkify('https://twitter.com/search?q=%23' + hashtag + '&src=hash', '#' + hashtag) # Linkify a mention until they are supported natively by friends if mention_name: content = self._linkify_mention(mention_name) if content: message = ''.join([message[:begin], content, message[end:]]) if retweet: message = 'RT {}: {}'.format( self._linkify_mention(retweet.get('user', {}).get('screen_name', '')), message ) if picture_url: stream = 'images' self._publish( message_id=tweet_id, message=message, timestamp=iso8601utc(parsetime(tweet.get('created_at', ''))), stream=stream, sender=user.get('name', ''), sender_id=str(user.get('id', '')), sender_nick=screen_name, from_me=(screen_name == self._account.user_name), icon_uri=avatar_url.replace('_normal.', '.'), liked=tweet.get('favorited', False), url=permalink, link_picture=picture_url, ) return permalink def _linkify_mention(self, name): return self._linkify('https://twitter.com/' + name, '@' + name) def _linkify(self, address, name): return '{}'.format(address, name) def _append_since(self, url, stream='messages'): since = self._tweet_ids.get(stream) if since is not None: return '{}&since_id={}'.format(url, since) return url # https://dev.twitter.com/docs/api/1.1/get/statuses/home_timeline @feature def home(self): """Gather the user's home timeline.""" url = '{}?count={}'.format( self._timeline.format('home'), self._DOWNLOAD_LIMIT) url = self._append_since(url) for tweet in self._get_url(url): self._publish_tweet(tweet) return self._get_n_rows() # https://dev.twitter.com/docs/api/1.1/get/statuses/mentions_timeline @feature def mentions(self): """Gather the tweets that mention us.""" url = '{}?count={}'.format( self._mentions_timeline, self._DOWNLOAD_LIMIT) url = self._append_since(url, 'mentions') for tweet in self._get_url(url): self._publish_tweet(tweet, stream='mentions') return self._get_n_rows() # https://dev.twitter.com/docs/api/1.1/get/statuses/user_timeline @feature def user(self, screen_name=''): """Gather the tweets from a specific user. If screen_name is not specified, then gather the tweets written by the currently authenticated user. """ url = self._user_timeline.format(screen_name) stream = 'user/{}'.format(screen_name) if screen_name else 'messages' for tweet in self._get_url(url): self._publish_tweet(tweet, stream=stream) return self._get_n_rows() # https://dev.twitter.com/docs/api/1.1/get/lists/statuses @feature def list(self, list_id): """Gather the tweets from the specified list_id.""" url = self._lists.format(list_id) for tweet in self._get_url(url): self._publish_tweet(tweet, stream='list/{}'.format(list_id)) return self._get_n_rows() # https://dev.twitter.com/docs/api/1.1/get/lists/list @feature def lists(self): """Gather the tweets from the lists that the we are subscribed to.""" url = self._api_base.format(endpoint='lists/list') for twitlist in self._get_url(url): self.list(twitlist.get('id_str', '')) return self._get_n_rows() # https://dev.twitter.com/docs/api/1.1/get/direct_messages # https://dev.twitter.com/docs/api/1.1/get/direct_messages/sent @feature def private(self): """Gather the direct messages sent to/from us.""" url = '{}?count={}'.format( self._api_base.format(endpoint='direct_messages'), self._DOWNLOAD_LIMIT) url = self._append_since(url, 'private') for tweet in self._get_url(url): self._publish_tweet(tweet, stream='private') url = '{}?count={}'.format( self._api_base.format(endpoint='direct_messages/sent'), self._DOWNLOAD_LIMIT) url = self._append_since(url, 'private') for tweet in self._get_url(url): self._publish_tweet(tweet, stream='private') return self._get_n_rows() @feature def receive(self): """Gather and publish all incoming messages.""" self.home() self.mentions() self.private() return self._get_n_rows() @feature def send_private(self, screen_name, message): """Send a direct message to the given screen name. This will error 403 if the person you are sending to does not follow you. """ url = self._api_base.format(endpoint='direct_messages/new') tweet = self._get_url( url, dict(text=message, screen_name=screen_name)) return self._publish_tweet(tweet, stream='private') # https://dev.twitter.com/docs/api/1.1/post/statuses/update @feature def send(self, message): """Publish a public tweet.""" url = self._api_base.format(endpoint='statuses/update') tweet = self._get_url(url, dict(status=message)) return self._publish_tweet(tweet) # https://dev.twitter.com/docs/api/1.1/post/statuses/update @feature def send_thread(self, message_id, message): """Send a reply message to message_id. This method takes care to prepend the @mention to the start of your tweet if you forgot it. Without this, Twitter will just consider it a regular message, and it won't be part of any conversation. """ with ignored(FriendsError): sender = '@{}'.format(self._fetch_cell(message_id, 'sender_nick')) if message.find(sender) < 0: message = sender + ' ' + message url = self._api_base.format(endpoint='statuses/update') tweet = self._get_url(url, dict(in_reply_to_status_id=message_id, status=message)) return self._publish_tweet( tweet, stream='reply_to/{}'.format(message_id)) # https://dev.twitter.com/docs/api/1.1/post/statuses/destroy/%3Aid @feature def delete(self, message_id): """Delete a tweet that you wrote.""" url = self._destroy.format(message_id) # We can ignore the return value. self._get_url(url, dict(trim_user='true')) self._unpublish(message_id) return message_id # https://dev.twitter.com/docs/api/1.1/post/statuses/retweet/%3Aid @feature def retweet(self, message_id): """Republish somebody else's tweet with your name on it.""" url = self._retweet.format(message_id) tweet = self._get_url(url, dict(trim_user='false')) return self._publish_tweet(tweet) # https://dev.twitter.com/docs/api/1.1/post/friendships/destroy @feature def unfollow(self, screen_name): """Stop following the given screen name.""" url = self._api_base.format(endpoint='friendships/destroy') self._get_url(url, dict(screen_name=screen_name)) return screen_name # https://dev.twitter.com/docs/api/1.1/post/friendships/create @feature def follow(self, screen_name): """Start following the given screen name.""" url = self._api_base.format(endpoint='friendships/create') self._get_url(url, dict(screen_name=screen_name, follow='true')) return screen_name # https://dev.twitter.com/docs/api/1.1/post/favorites/create @feature def like(self, message_id): """Announce to the world your undying love for a tweet.""" url = self._favorite.format(message_id) self._get_url(url, dict(id=message_id)) self._inc_cell(message_id, 'likes') self._set_cell(message_id, 'liked', True) return message_id # https://dev.twitter.com/docs/api/1.1/post/favorites/destroy @feature def unlike(self, message_id): """Renounce your undying love for a tweet.""" url = self._del_favorite.format(message_id) self._get_url(url, dict(id=message_id)) self._dec_cell(message_id, 'likes') self._set_cell(message_id, 'liked', False) return message_id # https://dev.twitter.com/docs/api/1.1/get/search/tweets @feature def tag(self, hashtag): """Return a list of some recent tweets mentioning hashtag.""" self.search('#' + hashtag.lstrip('#')) return self._get_n_rows() # https://dev.twitter.com/docs/api/1.1/get/search/tweets @feature def search(self, query): """Search for any arbitrary string.""" url = self._search response = self._get_url('{}?q={}'.format(url, quote(query, safe=''))) for tweet in response.get(self._search_result_key, []): self._publish_tweet(tweet, stream='search/{}'.format(query)) return self._get_n_rows() @feature def contacts(self): # https://dev.twitter.com/docs/api/1.1/get/friends/ids contacts = self._get_url(self._api_base.format(endpoint='friends/ids')) # Twitter uses a dict with 'ids' key, Identica returns the ids directly. with ignored(TypeError): contacts = contacts['ids'] log.debug('Found {} contacts'.format(len(contacts))) for contact_id in contacts: contact_id = str(contact_id) if not self._previously_stored_contact(contact_id): # https://dev.twitter.com/docs/api/1.1/get/users/show full_contact = self._get_url(url=self._api_base.format( endpoint='users/show') + '?user_id=' + contact_id) user_nickname = full_contact.get('screen_name', '') self._push_to_eds( uid=contact_id, name=full_contact.get('name'), nick=user_nickname, link=self._user_home.format(user_id=user_nickname)) return len(contacts) class TweetIdCache(JsonCache): """Persist most-recent tweet_ids as JSON.""" def __setitem__(self, key, value): if key.find('/') >= 0: # Don't flood the cache with irrelevant "reply_to/..." and # "search/..." streams, we only need the main streams. return value = int(value) if value > self.get(key, 0): JsonCache.__setitem__(self, key, value) class RateLimiter(BaseRateLimiter): """Twitter rate limiter.""" def __init__(self): self._limits = JsonCache('twitter-ratelimiter') def _sanitize_url(self, uri): # Cache the URL sans any query parameters. return uri.host + uri.path def wait(self, message): # If we haven't seen this URL, default to no wait. seconds = self._limits.pop(self._sanitize_url(message.get_uri()), 0) log.debug('Sleeping for {} seconds!'.format(seconds)) time.sleep(seconds) # Don't sleep the same length of time more than once! self._limits.write() def update(self, message): info = message.response_headers url = self._sanitize_url(message.get_uri()) # This is time in the future, in UTC epoch seconds, at which the # current rate limiting window expires. rate_reset = info.get('X-Rate-Limit-Reset') # This is the number of calls still allowed in this window. rate_count = info.get('X-Rate-Limit-Remaining') if None not in (rate_reset, rate_count): rate_reset = int(rate_reset) rate_count = int(rate_count) rate_delta = abs(rate_reset - time.time()) if rate_count > 5: # If there are more than 5 calls allowed in this window, then # do no rate limiting. pass elif rate_count < 1: # There are no calls remaining, so wait until the close of the # current window. self._limits[url] = rate_delta else: wait_secs = rate_delta / rate_count self._limits[url] = wait_secs log.debug( 'Next access to {} must wait {} seconds!'.format( url, self._limits.get(url, 0))) friends-0.2.0+14.04.20140217.1/friends/protocols/foursquare.py0000644000015201777760000000753712300444435024136 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """The FourSquare protocol plugin.""" __all__ = [ 'FourSquare', ] import logging from friends.utils.base import Base, feature from friends.utils.http import Downloader from friends.utils.time import iso8601utc from friends.errors import FriendsError log = logging.getLogger(__name__) # The '&v=YYYYMMDD' defines the date that the API was last confirmed to be # functional, and is used by foursquare to indicate how old our software is. # In the event that they change their API, an old 'v' date will tell them to # give us the old, deprecated API behaviors, giving us some time to be # notified of API breakage and update accordingly. If you're working on this # code and you don't see any bugs with foursquare then feel free to update the # date here. API_BASE = 'https://api.foursquare.com/v2/' TOKEN ='?oauth_token={access_token}&v=20121104' SELF_URL = API_BASE + 'users/self' + TOKEN CHECKIN_URL = API_BASE + 'checkins/{checkin_id}' + TOKEN RECENT_URL = API_BASE + 'checkins/recent' + TOKEN HTML_PREFIX = 'https://foursquare.com/' USER_URL = HTML_PREFIX + 'user/{user_id}' VENUE_URL = HTML_PREFIX + 'venue/{venue_id}' SPACE = ' ' def _full_name(user): names = (user.get('firstName'), user.get('lastName')) return SPACE.join([name for name in names if name]) class FourSquare(Base): def _whoami(self, authdata): """Identify the authenticating user.""" data = Downloader( SELF_URL.format(access_token=self._account.access_token)).get_json() user = data.get('response', {}).get('user', {}) self._account.secret_token = authdata.get('TokenSecret') self._account.user_name = _full_name(user) self._account.user_id = user.get('id') @feature def receive(self): """Gets a list of each friend's most recent check-ins.""" token = self._get_access_token() result = Downloader(RECENT_URL.format(access_token=token)).get_json() response_code = result.get('meta', {}).get('code') if response_code != 200: raise FriendsError('FourSquare: Error: {}'.format(result)) checkins = result.get('response', {}).get('recent', []) for checkin in checkins: user = checkin.get('user', {}) avatar = user.get('photo', {}) checkin_id = checkin.get('id', '') tz_offset = checkin.get('timeZoneOffset', 0) epoch = checkin.get('createdAt', 0) venue = checkin.get('venue', {}) location = venue.get('location', {}) self._publish( message_id=checkin_id, stream='messages', sender=_full_name(user), from_me=(user.get('relationship') == 'self'), timestamp=iso8601utc(epoch, tz_offset), message=checkin.get('shout', ''), likes=checkin.get('likes', {}).get('count', 0), icon_uri='{prefix}100x100{suffix}'.format(**avatar), url=venue.get('canonicalUrl', ''), location=venue.get('name', ''), latitude=location.get('lat', 0.0), longitude=location.get('lng', 0.0), ) return self._get_n_rows() friends-0.2.0+14.04.20140217.1/friends/protocols/flickr.py0000644000015201777760000001655312300444450023207 0ustar pbusernogroup00000000000000# friends-dispatcher -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Flickr plugin.""" __all__ = [ 'Flickr', ] import re import time import logging from urllib.parse import urlencode from friends.utils.base import Base, feature from friends.utils.http import Downloader, Uploader from friends.utils.time import iso8601utc, parsetime from friends.errors import FriendsError log = logging.getLogger(__name__) # http://www.flickr.com/services/api/request.rest.html REST_SERVER = 'http://api.flickr.com/services/rest' # http://www.flickr.com/services/api/upload.api.html UPLOAD_SERVER = 'http://api.flickr.com/services/upload' # http://www.flickr.com/services/api/misc.buddyicons.html FARM = 'http://farm{farm}.static.flickr.com/{server}/' BUDDY_ICON_URL = FARM + 'buddyicons/{nsid}.jpg' IMAGE_URL = FARM + '{photo}_{secret}_{type}.jpg' IMAGE_PAGE_URL = 'http://www.flickr.com/photos/{owner}/{photo}' PEOPLE_URL = 'http://www.flickr.com/people/{owner}' # Some regex for parsing XML when JSON is not available. PHOTOID = re.compile('(\d+)').search class Flickr(Base): def _whoami(self, authdata): """Identify the authenticating user.""" self._account.secret_token = authdata.get('TokenSecret') self._account.user_id = authdata.get('user_nsid') self._account.user_name = authdata.get('username') self._account.user_full_name = authdata.get('fullname') def _get_url(self, params=None): """Access the Flickr API with correct OAuth signed headers.""" method = 'GET' headers = self._get_oauth_headers( method=method, url='{}?{}'.format(REST_SERVER, urlencode(params)), ) response = Downloader( REST_SERVER, params=params, headers=headers, method=method, ).get_json() self._is_error(response) return response # http://www.flickr.com/services/api/flickr.people.getInfo.html def _get_avatar(self, nsid): args = dict( api_key=self._account.consumer_key, method='flickr.people.getInfo', format='json', nojsoncallback='1', user_id=nsid, ) response = self._get_url(args) person = response.get('person', {}) iconfarm = person.get('iconfarm') iconserver = person.get('iconserver') if None in (iconfarm, iconserver): return 'http://www.flickr.com/images/buddyicon.gif' return BUDDY_ICON_URL.format( farm=iconfarm, server=iconserver, nsid=nsid) # http://www.flickr.com/services/api/flickr.photos.getContactsPhotos.html @feature def receive(self): """Download all of a user's public photos.""" # Trigger loggin in. self._get_access_token() args = dict( api_key=self._account.consumer_key, method='flickr.photos.getContactsPhotos', format='json', nojsoncallback='1', extras='date_upload,owner_name,icon_server,geo', ) response = self._get_url(args) for data in response.get('photos', {}).get('photo', []): # Pre-calculate some values to publish. username = data.get('username', '') ownername = data.get('ownername', '') photo_id = data.get('id') if photo_id is None: # Can't do anything without this, really. continue # Icons. icon_farm = data.get('iconfarm') icon_server = data.get('iconserver') owner = data.get('owner') icon_uri = '' url = '' from_me = (ownername == username) if None not in (icon_farm, icon_server, owner): icon_uri = BUDDY_ICON_URL.format( farm=icon_farm, server=icon_server, nsid=owner) url = IMAGE_PAGE_URL.format(owner=owner, photo=photo_id) # Calculate the ISO 8601 UTC time string. try: timestamp = iso8601utc(parsetime(data.get('dateupload', ''))) except ValueError: timestamp = '' # Images. farm = data.get('farm') server = data.get('server') secret = data.get('secret') img_src, img_thumb = '', '' if None not in (farm, server, secret): args = dict( farm=farm, server=server, photo=photo_id, secret=secret, ) img_src = IMAGE_URL.format(type='m', **args) img_thumb = IMAGE_URL.format(type='t', **args) self._publish( message_id=photo_id, message=data.get('title', ''), stream='images', sender=ownername, sender_id=owner, sender_nick=ownername, icon_uri=icon_uri, url=url, from_me=from_me, timestamp=timestamp, link_url=url, link_picture=img_src, link_icon=img_thumb, latitude=data.get('latitude', 0.0), longitude=data.get('longitude', 0.0), ) return self._get_n_rows() # http://www.flickr.com/services/api/upload.api.html @feature def upload(self, picture_uri, title=''): """Upload local or remote image or video to album.""" self._get_access_token() args = dict( api_key=self._account.consumer_key, title=title, ) headers = self._get_oauth_headers( method='POST', url=UPLOAD_SERVER, data=args, ) response = Uploader( UPLOAD_SERVER, picture_uri, picture_key='photo', headers=headers, **args ).get_string() try: post_id = PHOTOID(response).group(1) except AttributeError: raise FriendsError(response) else: destination_url = IMAGE_PAGE_URL.format( owner=self._account.user_name, photo=post_id, ) self._publish( from_me=True, stream='images', message_id=post_id, message=title, sender=self._account.user_full_name, sender_id=self._account.user_id, sender_nick=self._account.user_name, timestamp=iso8601utc(int(time.time())), url=destination_url, icon_uri=self._get_avatar(self._account.user_id), ) return destination_url friends-0.2.0+14.04.20140217.1/Makefile0000644000015201777760000000142212300444435017335 0ustar pbusernogroup00000000000000# friends -- send & receive messages from any social network # Copyright (C) 2012 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . check: python3 -m unittest discover install: python3 setup.py install flakes: pyflakes friends friends-0.2.0+14.04.20140217.1/dispatcher.sh0000755000015201777760000000010612300444435020360 0ustar pbusernogroup00000000000000#! /bin/sh PYTHONPATH=. python3 -m friends.main --debug --console $@ friends-0.2.0+14.04.20140217.1/data/0000755000015201777760000000000012300444701016603 5ustar pbusernogroup00000000000000friends-0.2.0+14.04.20140217.1/data/com.canonical.friends.gschema.xml0000644000015201777760000000363112300444435025077 0ustar pbusernogroup00000000000000 60 Refresh interval in minutes. The number of minutes friends-service will wait in between attempts at downloading new messages. 'mentions-only' What kind of notifications should we display? Possible values are "all" for all notifications (warning: you will get spammed with a lot of notifications), "mentions-only" which will only notify you of messages that are addressed specifically to you, or "none", which hides all notifications. false Display debugging messages? Whether or not to show verbose debugging messages in the logfile. true Shorten URLs? Whether or not to automatically shorten URLs in messages that we send out to the world. "is.gd" URL shortening service. Choose the preferred URL shortening service. friends-0.2.0+14.04.20140217.1/data/model-schema.csv0000644000015201777760000000040112300444435021655 0ustar pbusernogroup00000000000000protocol,s account_id,t message_id,s stream,s sender,s sender_id,s sender_nick,s from_me,b timestamp,s message,s icon_uri,s url,s likes,t liked,b link_picture,s link_name,s link_url,s link_desc,s link_caption,s link_icon,s location,s latitude,d longitude,d friends-0.2.0+14.04.20140217.1/README.rst0000644000015201777760000000244712300444435017374 0ustar pbusernogroup00000000000000================== Installing Friends ================== Requirements ============ Package requirements. * gir1.2-dee-1.0 * gir1.2-gdkpixbuf-2.0 * gir1.2-glib-2.0 * gir1.2-networkmanager-1.0 * gir1.2-signon-1.0 * gir1.2-soup-2.4 * gir1.2-soup-gnome-2.4 * python3 (>= 3.2, although 3.3 will soon be required) * python3-distutils-extra * python3-dbus * gir1.2-ebook-1.2 * gir1.2-edataserver-1.2 * python3-mock Installation ============ It may be easier during development to run the service directly from the source directory. This should generally be good enough for development purposes, but again, doesn't exactly mimic how the service will be installed by the system package:: $ ./dispatcher.sh This is a little bit more fragile, since you must be in the top-level source directory for this to work. Once the service is running, it will access Ubuntu Online Accounts for all your microblogging-enabled accounts, and then retrieve all the public messages present on those accounts. Those messages can then be accessed over DBus, using a Dee.SharedModel. Testing ======= You can run the test suite with the command ``make check``. You can do some microblogging from the command line with:: $ ./tools/debug_live.py twitter send 'Hello, World!' friends-0.2.0+14.04.20140217.1/COPYING0000644000015201777760000007724612300444435016751 0ustar pbusernogroup00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.