infocalypse
 
(Steve Dougherty)
2013-07-26: Add abstraction for WoT identities.

Add abstraction for WoT identities. The WoT_ID class performs lookup from an identifier and exposes attributes. Local_WoT_ID extends WoT_ID and also exposes an insert URI. This avoids problems with being stringly typed and makes intent much clearer. Also adds a clone() to USK to avoid modifying the WoT_ID member variables.

diff --git a/infocalypse/commands.py b/infocalypse/commands.py
--- a/infocalypse/commands.py
+++ b/infocalypse/commands.py
@@ -41,8 +41,8 @@ def infocalypse_update_repo_list(ui, **o
         raise util.Abort("Update which repository list? Use --wot")
 
     import wot
-    local_id = wot.resolve_local_identity(ui, opts['wot'])
-    wot.update_repo_listing(ui, local_id['Identity'])
+    from wot_id import Local_WoT_ID
+    wot.update_repo_listing(ui, Local_WoT_ID(opts['wot']))
 
 
 def infocalypse_create(ui_, repo, **opts):
@@ -50,46 +50,34 @@ def infocalypse_create(ui_, repo, **opts
     params, stored_cfg = get_config_info(ui_, opts)
 
     insert_uri = ''
-    attributes = None
+    local_id = None
     if opts['uri'] != '' and opts['wot'] != '':
         ui_.warn("Please specify only one of --uri or --wot.\n")
         return
     elif opts['uri'] != '':
         insert_uri = opts['uri']
     elif opts['wot'] != '':
-        # Expecting nick_prefix/repo_name.R<redundancy num>/edition/
-        nick_prefix, repo_desc = opts['wot'].split('/', 1)
+        # Expecting nick_prefix/repo_name.R<redundancy num>/edition
+        nick_prefix, repo_name, repo_edition = opts['wot'].split('/', 2)
 
-        import wot
+        from wot_id import Local_WoT_ID
 
         ui_.status("Querying WoT for local identities.\n")
 
-        attributes = wot.resolve_local_identity(ui_, nick_prefix)
-        if attributes is None:
-            # Something went wrong; the function already printed an error.
-            return
+        local_id = Local_WoT_ID(nick_prefix)
 
-        ui_.status('Found {0}@{1}\n'.format(attributes['Nickname'],
-                                            attributes['Identity']))
+        ui_.status('Found {0}\n'.format(local_id))
 
-        insert_uri = attributes['InsertURI']
+        insert_uri = local_id.insert_uri.clone()
 
-        # LCWoT returns URIs with a "freenet:" prefix, and WoT does not. The
-        # rest of Infocalypse does not support the prefix. The local way to fix
-        # this is to remove it here, but the more flexible way that is also
-        # more work is to expand support to the rest of Infocalypse.
-        # TODO: More widespread support for "freenet:" URI prefix.
-        prefix = "freenet:"
-        if insert_uri.startswith(prefix):
-            insert_uri = insert_uri[len(prefix):]
-
-        # URI is USK@key/WebOfTrust/<edition>, but we only want USK@key
-        insert_uri = insert_uri.split('/', 1)[0]
-        insert_uri += '/' + repo_desc
+        insert_uri.name = repo_name
+        insert_uri.edition = repo_edition
+        # Before passing along into execute_create().
+        insert_uri = str(insert_uri)
 
         # Add "vcs" context. No-op if the identity already has it.
         msg_params = {'Message': 'AddContext',
-                      'Identity': attributes['Identity'],
+                      'Identity': local_id.identity_id,
                       'Context': 'vcs'}
 
         import fcp
@@ -116,14 +104,13 @@ def infocalypse_create(ui_, repo, **opts
     if inserted_to and opts['wot']:
         # TODO: Would it be friendlier to include the nickname as well?
         # creation returns a list of request URIs; use the first.
-        stored_cfg.set_wot_identity(inserted_to[0],
-                                    attributes['Identity'])
+        stored_cfg.set_wot_identity(inserted_to[0], local_id)
         Config.to_file(stored_cfg)
 
         # TODO: Imports don't go out of scope, right? The variables
         # from the import are only visible in the function, so yes.
         import wot
-        wot.update_repo_listing(ui_, attributes['Identity'])
+        wot.update_repo_listing(ui_, local_id)
 
 
 def infocalypse_copy(ui_, repo, **opts):
@@ -201,9 +188,6 @@ def infocalypse_pull(ui_, repo, **opts):
         truster = get_truster(ui_, repo, opts)
 
         request_uri = wot.resolve_pull_uri(ui_, opts['wot'], truster)
-
-        if request_uri is None:
-            return
     else:
         request_uri = opts['uri']
 
@@ -221,22 +205,25 @@ def infocalypse_pull(ui_, repo, **opts):
 
 def infocalypse_pull_request(ui, repo, **opts):
     import wot
+    from wot_id import WoT_ID
     if not opts['wot']:
         raise util.Abort("Who do you want to send the pull request to? Set "
                          "--wot.\n")
 
     wot_id, repo_name = opts['wot'].split('/', 1)
-    wot.send_pull_request(ui, repo, get_truster(ui, repo, opts), wot_id,
-                          repo_name)
+    from_identity = get_truster(ui, repo, opts)
+    to_identity = WoT_ID(wot_id, from_identity)
+    wot.send_pull_request(ui, repo, from_identity, to_identity, repo_name)
 
 
 def infocalypse_check_notifications(ui, repo, **opts):
     import wot
+    from wot_id import Local_WoT_ID
     if not opts['wot']:
         raise util.Abort("What ID do you want to check for notifications? Set"
                          " --wot.\n")
 
-    wot.check_notifications(ui, opts['wot'])
+    wot.check_notifications(ui, Local_WoT_ID(opts['wot']))
 
 
 def infocalypse_connect(ui, repo, **opts):
@@ -480,8 +467,7 @@ def infocalypse_setup(ui_, **opts):
         ui_.status("Skipped FMS configuration because --nofms was set.\n")
 
     if not opts['nowot']:
-        import wot
-        wot.execute_setup_wot(ui_, opts)
+        infocalypse_setupwot(ui_, **opts)
     else:
         ui_.status("Skipped WoT configuration because --nowot was set.\n")
 
@@ -494,8 +480,12 @@ def infocalypse_setupfms(ui_, **opts):
 
 # TODO: Why ui with trailing underscore? Is there a global "ui" somewhere?
 def infocalypse_setupwot(ui_, **opts):
+    if not opts['truster']:
+        util.Abort("Specify default truster with --truster")
+
     import wot
-    wot.execute_setup_wot(ui_, opts)
+    from wot_id import Local_WoT_ID
+    wot.execute_setup_wot(ui_, Local_WoT_ID(opts['truster']))
 
 
 def infocalypse_setupfreemail(ui, repo, **opts):
@@ -511,21 +501,22 @@ def infocalypse_setupfreemail(ui, repo, 
 
 def get_truster(ui, repo, opts):
     """
-    Return a local WoT ID.
-
-    TODO: Check that it actually is local? Classes would be nice for this.
-    Being stringly typed as present requires a lot of checking and re-checking.
+    Return a local WoT ID - either one that published this repository or the
+    default.
+    :rtype : Local_WoT_ID
     """
+    from wot_id import Local_WoT_ID
     if opts['truster']:
-        return opts['truster']
+        return Local_WoT_ID(opts['truster'])
     else:
         cfg = Config().from_ui(ui)
 
+        # Value is identity ID.
         identity = cfg.get_wot_identity(cfg.get_request_uri(repo.root))
         if not identity:
             identity = cfg.defaults['DEFAULT_TRUSTER']
 
-        return '@' + identity
+        return Local_WoT_ID('@' + identity)
 
 #----------------------------------------------------------"
 
diff --git a/infocalypse/config.py b/infocalypse/config.py
--- a/infocalypse/config.py
+++ b/infocalypse/config.py
@@ -127,6 +127,7 @@ class Config:
         self.insert_usks = {}
         # repo id -> publisher WoT identity
         self.wot_identities = {}
+        # TODO: Should this be keyed by str(WoT_ID) ?
         # WoT public key hash -> Freemail password
         self.freemail_passwords = {}
         # fms_id -> (usk_hash, ...) map
@@ -208,8 +209,10 @@ class Config:
     def set_wot_identity(self, for_usk_or_id, wot_identity):
         """
         Set the WoT identity associated with the request USK.
+        :type wot_identity: WoT_ID
         """
-        self.wot_identities[normalize(for_usk_or_id)] = wot_identity
+        self.wot_identities[normalize(for_usk_or_id)] =\
+            wot_identity.identity_id
 
     def get_wot_identity(self, for_usk_or_id):
         """
@@ -225,20 +228,21 @@ class Config:
         """
         Set the password for the given WoT identity.
         """
-        self.freemail_passwords[wot_identity] = password
+        self.freemail_passwords[wot_identity.identity_id] = password
 
     def get_freemail_password(self, wot_identity):
         """
         Return the password associated with the given WoT identity.
         Raise util.Abort if one is not set.
+        :type wot_identity: WoT_ID
         """
-        if wot_identity in self.freemail_passwords:
-            return self.freemail_passwords[wot_identity]
+        identity_id = wot_identity.identity_id
+        if identity_id in self.freemail_passwords:
+            return self.freemail_passwords[identity_id]
         else:
             raise util.Abort("{0} does not have a Freemail password set.\n"
-                             "Run hg fn-setupfreemail --truster {0}@{1}\n"
-                             .format(wot_identity['Nickname'],
-                                     wot_identity['Identity']))
+                             "Run hg fn-setupfreemail --truster {0}\n"
+                             .format(wot_identity))
 
     # Hmmm... really nescessary?
     def get_dir_insert_uri(self, repo_dir):
diff --git a/infocalypse/keys.py b/infocalypse/keys.py
--- a/infocalypse/keys.py
+++ b/infocalypse/keys.py
@@ -17,6 +17,9 @@ class USK:
         elif self.key.startswith('freenet://'):
             self.key = self.key[len('freenet://'):]
 
+    def clone(self):
+        return USK(str(self))
+
     def __str__(self):
         return '%s/%s/%s' % (self.key, self.name, self.edition)
 
diff --git a/infocalypse/wot.py b/infocalypse/wot.py
--- a/infocalypse/wot.py
+++ b/infocalypse/wot.py
@@ -1,17 +1,15 @@
-import string
 import fcp
 from mercurial import util
 from config import Config
 import xml.etree.ElementTree as ET
 from defusedxml.ElementTree import fromstring
 import smtplib
-from base64 import b32encode
-from fcp.node import base64decode
 from keys import USK
 import yaml
 from email.mime.text import MIMEText
 import imaplib
 import threading
+from wot_id import Local_WoT_ID, WoT_ID
 
 FREEMAIL_SMTP_PORT = 4025
 FREEMAIL_IMAP_PORT = 4143
@@ -78,20 +76,20 @@ def connect(ui, repo):
             print ack
 
 
-def send_pull_request(ui, repo, from_identifier, to_identifier, to_repo_name):
-    local_identity = resolve_local_identity(ui, from_identifier)
+def send_pull_request(ui, repo, from_identity, to_identity, to_repo_name):
+    """
 
-    target_identity = resolve_identity(ui, local_identity['Identity'],
-                                       to_identifier)
 
-    from_address = to_freemail_address(local_identity)
-    to_address = to_freemail_address(target_identity)
+    :type to_identity: WoT_ID
+    :type from_identity: Local_WoT_ID
+    """
+    from_address = require_freemail(from_identity)
+    to_address = require_freemail(to_identity)
 
     cfg = Config.from_ui(ui)
-    password = cfg.get_freemail_password(local_identity['Identity'])
+    password = cfg.get_freemail_password(from_identity.identity_id)
 
-    to_repo = find_repo(ui, local_identity['Identity'], to_identifier,
-                        to_repo_name)
+    to_repo = find_repo(ui, to_identity, to_repo_name)
 
     repo_context = repo['tip']
     # TODO: Will there always be a request URI set in the config? What about
@@ -123,7 +121,7 @@ HG: Enter pull request message here. Lin
 HG: The first line has "{0}" added before it in transit and is the subject.
 HG: The second line should be blank.
 HG: Following lines are the body of the message.
-""".format(VCS_TOKEN), from_identifier)
+""".format(VCS_TOKEN), str(from_identity))
     # TODO: Save message and load later in case sending fails.
 
     source_lines = source_text.splitlines()
@@ -147,14 +145,17 @@ HG: Following lines are the body of the 
     ui.status("Pull request sent.\n")
 
 
-def check_notifications(ui, sent_to_identifier):
-    local_identity = resolve_local_identity(ui, sent_to_identifier)
-    address = to_freemail_address(local_identity)
+def check_notifications(ui, local_identity):
+    """
+
+    :type local_identity: Local_WoT_ID
+    """
+    address = require_freemail(local_identity)
 
     # Log in and open inbox.
     cfg = Config.from_ui(ui)
     imap = imaplib.IMAP4(cfg.defaults['HOST'], FREEMAIL_IMAP_PORT)
-    imap.login(address, cfg.get_freemail_password(local_identity['Identity']))
+    imap.login(address, cfg.get_freemail_password(local_identity))
     imap.select()
 
     # Parenthesis to work around erroneous quotes:
@@ -262,17 +263,31 @@ def read_message_yaml(ui, from_address, 
               % (subject, request['request']))
 
 
+def require_freemail(wot_identity):
+    """
+    Return the given identity's Freemail address.
+    Abort with an error message if the given identity does not have a
+    Freemail address / context.
+    :type wot_identity: WoT_ID
+    """
+    if not wot_identity.freemail_address:
+        raise util.Abort("{0} is not using Freemail.\n".format(wot_identity))
+
+    return wot_identity.freemail_address
+
+
 def update_repo_listing(ui, for_identity):
     # TODO: WoT property containing edition. Used when requesting.
     # Version number to support possible format changes.
+    """
+
+    :type for_identity: Local_WoT_ID
+    """
     root = ET.Element('vcs', {'version': '0'})
 
     ui.status("Updating repo listing for '%s'\n" % for_identity)
 
-    # Key goes after @ - before is nickname.
-    wot_id = '@' + for_identity
-
-    for request_uri in build_repo_list(ui, wot_id):
+    for request_uri in build_repo_list(ui, for_identity):
         repo = ET.SubElement(root, 'repository', {
             'vcs': 'Infocalypse',
         })
@@ -281,8 +296,7 @@ def update_repo_listing(ui, for_identity
     # TODO: Nonstandard IP and port.
     node = fcp.FCPNode()
 
-    attributes = resolve_local_identity(ui, wot_id)
-    insert_uri = USK(attributes['InsertURI'])
+    insert_uri = for_identity.insert_uri.clone()
 
     # TODO: Somehow store the edition, perhaps in ~/.infocalypse. WoT
     # properties are apparently not appropriate.
@@ -300,61 +314,50 @@ def update_repo_listing(ui, for_identity
         ui.status("Updated repository listing:\n{0}\n".format(uri))
 
 
-def build_repo_list(ui, wot_id):
+def build_repo_list(ui, for_identity):
     """
     Return a list of request URIs to repos for the given local identity.
-    TODO: Does local identities only make sense?
 
+    :type for_identity: Local_WoT_ID
     :param ui: to provide feedback
-    :param wot_id: local WoT identity to list repos for.
+    :param for_identity: local WoT identity to list repos for.
     """
-    # TODO: Repetitive local ID resolution between here and repo listing
-    # update. This only needs key - perhaps higher-level stuff should always
-    # take a wot_id?
-    local_id = resolve_local_identity(ui, wot_id)
-
     config = Config.from_ui(ui)
 
     repos = []
 
     # Add request URIs associated with the given identity.
     for request_uri in config.request_usks.itervalues():
-        if config.get_wot_identity(request_uri) == local_id['Identity']:
+        if config.get_wot_identity(request_uri) == for_identity.identity_id:
             repos.append(request_uri)
 
     return repos
 
 
-def find_repo(ui, truster, wot_identifier, repo_name):
+def find_repo(ui, identity, repo_name):
     """
     Return a request URI for a repo of the given name published by an
     identity matching the given identifier.
     Raise util.Abort if unable to read repo listing or a repo by that name
     does not exist.
+    :type identity: WoT_ID
     """
-    listing = read_repo_listing(ui, truster, wot_identifier)
+    listing = read_repo_listing(ui, identity)
 
     if repo_name not in listing:
-        # TODO: Perhaps resolve again; print full nick / key?
-        # TODO: Maybe print key found in the resolve_*identity?
         raise util.Abort("{0} does not publish a repo named '{1}'\n"
-                         .format(wot_identifier, repo_name))
+                         .format(identity, repo_name))
 
     return listing[repo_name]
 
 
-def read_repo_listing(ui, truster, wot_identifier):
+def read_repo_listing(ui, identity):
     """
     Read a repo listing for a given identity.
     Return a dictionary of repository request URIs keyed by name.
-    Raise util.Abort if unable to resolve identity.
+    :type identity: WoT_ID
     """
-    identity = resolve_identity(ui, truster, wot_identifier)
-
-    ui.status("Found {0}@{1}.\n".format(identity['Nickname'],
-                                        identity['Identity']))
-
-    uri = USK(identity['RequestURI'])
+    uri = identity.request_uri.clone()
     uri.name = 'vcs'
     uri.edition = 0
 
@@ -383,7 +386,8 @@ def read_repo_listing(ui, truster, wot_i
 def resolve_pull_uri(ui, path, truster):
         """
         Return a pull URI for the given path.
-        Print an error message and return None on failure.
+        Print an error message and abort on failure.
+        :type truster: Local_WoT_ID
         TODO: Is it appropriate to outline possible errors?
         Possible failures are being unable to fetch a repo list for the given
         identity, which may be a fetch failure or being unable to find the
@@ -397,10 +401,12 @@ def resolve_pull_uri(ui, path, truster):
         # Expecting <id stuff>/reponame
         wot_id, repo_name = path.split('/', 1)
 
+        identity = WoT_ID(wot_id, truster)
+
         # TODO: How to handle redundancy? Does Infocalypse automatically try
         # an R0 if an R1 fails?
 
-        return find_repo(ui, truster, wot_id, repo_name)
+        return find_repo(ui, identity, repo_name)
 
 
 def resolve_push_uri(ui, path):
@@ -413,21 +419,20 @@ def resolve_push_uri(ui, path):
     where the identity is a local one. (Such that the insert URI is known.)
     """
     # Expecting <id stuff>/repo_name
-    # TODO: Duplicate with resolve_pull
     wot_id, repo_name = path.split('/', 1)
 
-    local_id = resolve_local_identity(ui, wot_id)
+    local_id = Local_WoT_ID(wot_id)
 
-    insert_uri = USK(local_id['InsertURI'])
+    insert_uri = local_id.insert_uri
 
-    identifier = local_id['Nickname'] + '@' + local_id['Identity']
-
-    repo = find_repo(ui, local_id['Identity'], identifier, repo_name)
+    # TODO: find_repo should make it clearer that it returns a request URI,
+    # and return a USK.
+    repo = find_repo(ui, local_id, repo_name)
 
     # Request URI
     repo_uri = USK(repo)
 
-    # Maintains path, edition.
+    # Maintains name, edition.
     repo_uri.key = insert_uri.key
 
     return str(repo_uri)
@@ -437,33 +442,27 @@ def resolve_push_uri(ui, path):
 # TODO: "cmds" suffix to module name to fit fms, arc, inf?
 
 
-def execute_setup_wot(ui_, opts):
+def execute_setup_wot(ui_, local_id):
     cfg = Config.from_ui(ui_)
-    response = resolve_local_identity(ui_, opts['truster'])
 
-    ui_.status("Setting default truster to {0}@{1}\n".format(
-        response['Nickname'],
-        response['Identity']))
+    ui_.status("Setting default truster to {0}.\n".format(local_id))
 
-    cfg.defaults['DEFAULT_TRUSTER'] = response['Identity']
+    cfg.defaults['DEFAULT_TRUSTER'] = local_id.identity_id
     Config.to_file(cfg)
 
 
-def execute_setup_freemail(ui, wot_identifier):
+def execute_setup_freemail(ui, local_id):
     """
     Prompt for, test, and set a Freemail password for the identity.
     """
-    local_id = resolve_local_identity(ui, wot_identifier)
-
-    address = to_freemail_address(local_id)
+    address = require_freemail(local_id)
 
     password = ui.getpass()
     if password is None:
         raise util.Abort("Cannot prompt for a password in a non-interactive "
                          "context.\n")
 
-    ui.status("Checking password for {0}@{1}.\n".format(local_id['Nickname'],
-                                                        local_id['Identity']))
+    ui.status("Checking password for {0}.\n".format(local_id))
 
     cfg = Config.from_ui(ui)
 
@@ -479,245 +478,6 @@ def execute_setup_freemail(ui, wot_ident
         raise util.Abort("Could not connect to server.\nGot '{0}'\n"
                          .format(e.smtp_error))
 
-    cfg.set_freemail_password(local_id['Identity'], password)
+    cfg.set_freemail_password(local_id, password)
     Config.to_file(cfg)
     ui.status("Password set.\n")
-
-
-def resolve_local_identity(ui, wot_identifier):
-    """
-    Mercurial ui for error messages.
-
-    Returns a dictionary of the nickname, insert and request URIs,
-    and identity that match the given criteria.
-    In the case of an error prints a message and returns None.
-    """
-    nickname_prefix, key_prefix = parse_name(wot_identifier)
-
-    node = fcp.FCPNode()
-    response = \
-        node.fcpPluginMessage(async=False,
-                              plugin_name="plugins.WebOfTrust.WebOfTrust",
-                              plugin_params={'Message':
-                                             'GetOwnIdentities'})[0]
-
-    if response['header'] != 'FCPPluginReply' or \
-            'Replies.Message' not in response or \
-            response['Replies.Message'] != 'OwnIdentities':
-        raise util.Abort("Unexpected reply. Got {0}\n.".format(response))
-
-    # Find nicknames starting with the supplied nickname prefix.
-    prefix = 'Replies.Nickname'
-    # Key: nickname, value (id_num, public key hash).
-    matches = {}
-    for key in response.iterkeys():
-        if key.startswith(prefix) and \
-                response[key].startswith(nickname_prefix):
-
-            # Key is Replies.Nickname<number>, where number is used in
-            # the other attributes returned for that identity.
-            id_num = key[len(prefix):]
-
-            nickname = response[key]
-            pubkey_hash = response['Replies.Identity{0}'.format(id_num)]
-
-            matches[nickname] = (id_num, pubkey_hash)
-
-    # Remove matching nicknames not also matching the (possibly partial)
-    # public key hash.
-    for key in matches.keys():
-        # public key hash is second member of value tuple.
-        if not matches[key][1].startswith(key_prefix):
-            del matches[key]
-
-    if len(matches) > 1:
-        raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier))
-
-    if len(matches) == 0:
-        raise util.Abort("No local identities match '{0}'.\n".format(
-            wot_identifier))
-
-    assert len(matches) == 1
-
-    # id_num is first member of value tuple.
-    only_key = matches.keys()[0]
-    id_num = matches[only_key][0]
-
-    return read_local_identity(response, id_num)
-
-
-def resolve_identity(ui, truster, wot_identifier):
-    """
-    If using LCWoT, either the nickname prefix should be enough to be
-    unambiguous, or failing that enough of the key.
-    If using WoT, partial search is not supported, and the entire key must be
-    specified.
-
-    Returns a dictionary of the nickname, request URI,
-    and identity that matches the given criteria.
-    In the case of an error prints a message and returns None.
-
-    :param ui: Mercurial ui for error messages.
-    :param truster: Check trust list of this local identity.
-    :param wot_identifier: Nickname and key, delimited by @. Either half can be
-    omitted.
-    """
-    nickname_prefix, key_prefix = parse_name(wot_identifier)
-    # TODO: Support different FCP IP / port.
-    node = fcp.FCPNode()
-
-    # Test for GetIdentitiesByPartialNickname support. currently LCWoT-only.
-    # src/main/java/plugins/WebOfTrust/fcp/GetIdentitiesByPartialNickname.java
-    # TODO: LCWoT allows limiting by context, but how to make sure otherwise?
-    # TODO: Should this manually ensure an identity has a vcs context
-    # otherwise?
-
-    # LCWoT can have * to allow a wildcard match, but a wildcard alone is not
-    # allowed. See Lucine Term Modifiers documentation. The nickname uses
-    # this syntax but the ID is inherently startswith().
-    params = {'Message': 'GetIdentitiesByPartialNickname',
-              'Truster': truster,
-              'PartialNickname':
-              nickname_prefix + '*' if nickname_prefix else '',
-              'PartialID': key_prefix,
-              'MaxIdentities': 2,
-              'Context': 'vcs'}
-
-    response = \
-        node.fcpPluginMessage(async=False,
-                              plugin_name="plugins.WebOfTrust.WebOfTrust",
-                              plugin_params=params)[0]
-
-    if response['header'] != 'FCPPluginReply' or \
-            'Replies.Message' not in response:
-        raise util.Abort('Unexpected reply. Got {0}\n'.format(response))
-    elif response['Replies.Message'] == 'Identities':
-        matches = response['Replies.IdentitiesMatched']
-        if matches == 0:
-            raise util.Abort("No identities match '{0}'\n".format(
-                wot_identifier))
-        elif matches == 1:
-            return read_identity(response, 0)
-        else:
-            raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier))
-
-    # Partial matching not supported, or unknown truster. The only difference
-    # in the errors is human-readable, so just try the exact match.
-    assert response['Replies.Message'] == 'Error'
-
-    # key_prefix must be a complete key for the lookup to succeed.
-    params = {'Message': 'GetIdentity',
-              'Truster': truster,
-              'Identity': key_prefix}
-    response = \
-        node.fcpPluginMessage(async=False,
-                              plugin_name="plugins.WebOfTrust.WebOfTrust",
-                              plugin_params=params)[0]
-
-    if response['Replies.Message'] == 'Error':
-        # Searching by exact public key hash, not matching.
-        raise util.Abort("No such identity '{0}'.\n".format(wot_identifier))
-
-    # There should be only one result.
-    # Depends on https://bugs.freenetproject.org/view.php?id=5729
-    return read_identity(response, 0)
-
-
-def read_local_identity(message, id_num):
-    """
-    Reads an FCP response from a WoT plugin describing a local identity and
-    returns a dictionary of Nickname, InsertURI, RequestURI, Identity, and
-    each numbered Context.
-    """
-    result = read_identity(message, id_num)
-    result['InsertURI'] = message['Replies.InsertURI{0}'.format(id_num)]
-    return result
-
-
-def read_identity(message, id_num):
-    """
-    Reads an FCP response from a WoT plugin describing an identity and
-    returns a dictionary of Nickname, RequestURI, Identity, and Contexts.
-    """
-    # Return properties for the selected identity. (by number)
-    result = {}
-    for item in ['Nickname', 'RequestURI', 'Identity']:
-        result[item] = message['Replies.{0}{1}'.format(item, id_num)]
-
-    # LCWoT also puts these things as properties, which would be nicer to
-    # depend on and would allow just returning all properties for the identity.
-    #property_prefix = "Replies.Properties{0}".format(id_num)
-
-    # Add contexts and other properties.
-    # TODO: Unflattening WoT response? Several places check for prefix like
-    # this.
-    context_prefix = "Replies.Contexts{0}.Context".format(id_num)
-    property_prefix = "Replies.Properties{0}.Property".format(id_num)
-    for key in message.iterkeys():
-        if key.startswith(context_prefix):
-            num = key[len(context_prefix):]
-            result["Context{0}".format(num)] = message[key]
-        elif key.startswith(property_prefix) and key.endswith(".Name"):
-            # ".Name" is 5 characters, before which is the number.
-            num = key[len(property_prefix):-5]
-
-            # Example:
-            # Replies.Properties1.Property1.Name = IntroductionPuzzleCount
-            # Replies.Properties1.Property1.Value = 10
-            name = message[key]
-            value = message[property_prefix + num + '.Value']
-
-            # LCWoT returns many things with duplicates in properties,
-            # so this conflict is something that can happen. Checking for
-            # value conflict restricts the message to cases where it actually
-            # has an effect.
-            if name in result and value != result[name]:
-                print("WARNING: '{0}' has a different value as a property."
-                      .format(name))
-
-            result[name] = value
-
-    return result
-
-
-def parse_name(wot_identifier):
-    """
-    Parse identifier of the forms: nick
-                                   nick@key
-                                   @key
-    Return nick, key. If a part is not given return an empty string for it.
-    """
-    split = wot_identifier.split('@', 1)
-    nickname_prefix = split[0]
-
-    key_prefix = ''
-    if len(split) == 2:
-        key_prefix = split[1]
-
-    return nickname_prefix, key_prefix
-
-
-def to_freemail_address(identity):
-    """
-    Return a Freemail address to contact the given identity if it has a
-    Freemail context.
-    Raise util.Abort if it does not have a Freemail context.
-    """
-
-    # Freemail addresses encode the public key hash with base32 instead of
-    # base64 as WoT does. This is to be case insensitive because email
-    # addresses are not case sensitive, so some clients may mangle case.
-    # See https://github.com/zidel/Freemail/blob/v0.2.2.1/docs/spec/spec.tex#L32
-
-    for item in identity.iteritems():
-        if item[1] == 'Freemail' and item[0].startswith('Context'):
-            re_encode = b32encode(base64decode(identity['Identity']))
-            # Remove trailing '=' padding.
-            re_encode = re_encode.rstrip('=')
-
-            # Freemail addresses are lower case.
-            return string.lower(identity['Nickname'] + '@' + re_encode +
-                                '.freemail')
-
-    raise util.Abort("{0}@{1} is not using Freemail.\n".format(
-        identity['Nickname'], identity['Identity']))
diff --git a/infocalypse/wot_id.py b/infocalypse/wot_id.py
new file mode 100644
--- /dev/null
+++ b/infocalypse/wot_id.py
@@ -0,0 +1,247 @@
+import fcp
+from mercurial import util
+import string
+from keys import USK
+from base64 import b32encode
+from fcp.node import base64decode
+
+
+class WoT_ID(object):
+    """
+    Represents a WoT ID.
+
+    TODO: Is this list appropriate to have?
+    * nickname - str
+    * request_uri - USK
+    * identity_id - str
+    * contexts - list
+    * properties - dict
+    * freemail_address - str
+    """
+
+    def __init__(self, wot_identifier, truster, id_num=0, message=None):
+        """
+        If using LCWoT, either the nickname prefix should be enough to be
+        unambiguous, or failing that enough of the key.
+        If using WoT, partial search is not supported, and the entire key must
+        be specified.
+
+        :type truster: Local_WoT_ID
+        :type wot_identifier: str
+        :param truster: Check trust list of this local identity.
+        :param wot_identifier: Nickname and key, delimited by @. Either half can
+        be omitted.
+        """
+        # id_num and message are internal and used to allow constructing
+        # a WoT_ID for a Local_WoT_ID.
+        if not message:
+            message = get_identity(wot_identifier, truster)
+
+        def get_attribute(attribute):
+            return message['Replies.{0}{1}'.format(attribute, id_num)]
+
+        self.nickname = get_attribute('Nickname')
+        self.request_uri = USK(get_attribute('RequestURI'))
+        self.identity_id = get_attribute('Identity')
+
+        # Add contexts and other properties.
+        # TODO: Unflattening WoT response? Several places check for prefix like
+        # this.
+        self.contexts = []
+        self.properties = {}
+        context_prefix = "Replies.Contexts{0}.Context".format(id_num)
+        property_prefix = "Replies.Properties{0}.Property".format(id_num)
+        for key in message.iterkeys():
+            if key.startswith(context_prefix):
+                #num = key[len(context_prefix):]
+                # TODO: Is there a reason to keep the numbers? (As in key it.)
+                #identity.contexts["Context{0}".format(num)] = message[key]
+                self.contexts.append(message[key])
+            elif key.startswith(property_prefix) and key.endswith(".Name"):
+                # ".Name" is 5 characters, before which is the number.
+                num = key[len(property_prefix):-5]
+
+                # Example:
+                # Replies.Properties1.Property1.Name = IntroductionPuzzleCount
+                # Replies.Properties1.Property1.Value = 10
+                name = message[key]
+                value = message[property_prefix + num + '.Value']
+
+                # LCWoT returns many things with duplicates in properties,
+                # so this conflict is something that can happen. Checking for
+                # value conflict restricts the message to cases where it
+                # actually has an effect.
+                if name in self.properties and value != self.properties[name]:
+                    print("WARNING: '{0}' has conflicting value as a property."
+                          .format(name))
+
+                self.properties[name] = value
+
+        # Freemail addresses encode the public key hash with base32 instead of
+        # base64 as WoT does. This is to be case insensitive because email
+        # addresses are not case sensitive, so some clients may mangle case.
+        # See:
+        # https://github.com/zidel/Freemail/blob/v0.2.2.1/docs/spec/spec.tex#L32
+
+        if not 'Freemail' in self.contexts:
+            self.freemail_address = None
+        else:
+            re_encode = b32encode(base64decode(self.identity_id))
+            # Remove trailing '=' padding.
+            re_encode = re_encode.rstrip('=')
+
+            # Freemail addresses are lower case.
+            self.freemail_address = string.lower(self.nickname + '@' + re_encode
+                                                 + '.freemail')
+
+    def __str__(self):
+        return self.nickname + '@' + self.identity_id
+
+
+class Local_WoT_ID(WoT_ID):
+    """
+    Represents a local WoT ID.
+
+    * nickname - str
+    * request_uri - USK
+    * insert_uri - USK
+    * identity_id - str
+    * contexts - list
+    * properties - dict
+    """
+
+    def __init__(self, wot_identifier):
+        nickname_prefix, key_prefix = parse_name(wot_identifier)
+
+        node = fcp.FCPNode()
+        response = \
+            node.fcpPluginMessage(async=False,
+                                  plugin_name="plugins.WebOfTrust.WebOfTrust",
+                                  plugin_params={'Message':
+                                                 'GetOwnIdentities'})[0]
+
+        if response['header'] != 'FCPPluginReply' or \
+                'Replies.Message' not in response or \
+                response['Replies.Message'] != 'OwnIdentities':
+            raise util.Abort("Unexpected reply. Got {0}\n.".format(response))
+
+        # Find nicknames starting with the supplied nickname prefix.
+        prefix = 'Replies.Nickname'
+        # Key: nickname, value (id_num, public key hash).
+        matches = {}
+        for key in response.iterkeys():
+            if key.startswith(prefix) and \
+                    response[key].startswith(nickname_prefix):
+
+                # Key is Replies.Nickname<number>, where number is used in
+                # the other attributes returned for that identity.
+                id_num = key[len(prefix):]
+
+                nickname = response[key]
+                pubkey_hash = response['Replies.Identity{0}'.format(id_num)]
+
+                matches[nickname] = (id_num, pubkey_hash)
+
+        # Remove matching nicknames not also matching the (possibly partial)
+        # public key hash.
+        for key in matches.keys():
+            # public key hash is second member of value tuple.
+            if not matches[key][1].startswith(key_prefix):
+                del matches[key]
+
+        if len(matches) > 1:
+            raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier))
+
+        if len(matches) == 0:
+            raise util.Abort("No local identities match '{0}'.\n".format(
+                wot_identifier))
+
+        assert len(matches) == 1
+
+        # id_num is first member of value tuple.
+        only_key = matches.keys()[0]
+        id_num = matches[only_key][0]
+
+        self.insert_uri = USK(response['Replies.InsertURI{0}'.format(id_num)])
+
+        WoT_ID.__init__(self, None, None, id_num=id_num, message=response)
+
+
+def get_identity(wot_identifier, truster):
+    nickname_prefix, key_prefix = parse_name(wot_identifier)
+    # TODO: Support different FCP IP / port.
+    node = fcp.FCPNode()
+
+    # Test for GetIdentitiesByPartialNickname support. currently LCWoT-only.
+    # src/main/java/plugins/WebOfTrust/fcp/GetIdentitiesByPartialNickname
+    # TODO: LCWoT allows limiting by context; how to make sure otherwise?
+    # TODO: Should this manually ensure an identity has a vcs context
+    # otherwise?
+
+    # LCWoT can have * to allow a wildcard match, but a wildcard alone is
+    # not allowed. See Lucine Term Modifiers documentation. The nickname
+    # uses this syntax but the ID is inherently startswith().
+    params = {'Message': 'GetIdentitiesByPartialNickname',
+              'Truster': truster.identity_id,
+              'PartialNickname':
+              nickname_prefix + '*' if nickname_prefix else '',
+              'PartialID': key_prefix,
+              'MaxIdentities': 2,
+              'Context': 'vcs'}
+
+    response = \
+        node.fcpPluginMessage(async=False,
+                              plugin_name="plugins.WebOfTrust.WebOfTrust",
+                              plugin_params=params)[0]
+
+    if response['header'] != 'FCPPluginReply' or \
+            'Replies.Message' not in response:
+        raise util.Abort('Unexpected reply. Got {0}\n'.format(response))
+    elif response['Replies.Message'] == 'Identities':
+        matches = response['Replies.IdentitiesMatched']
+        if matches == 0:
+            raise util.Abort("No identities match '{0}'\n".format(
+                wot_identifier))
+        elif matches == 1:
+            return response
+        else:
+            raise util.Abort("'{0}' is ambiguous.\n".format(wot_identifier))
+
+    # Partial matching not supported, or unknown truster. The only
+    # difference in the errors is human-readable, so just try the exact
+    # match.
+    assert response['Replies.Message'] == 'Error'
+
+    # key_prefix must be a complete key for the lookup to succeed.
+    params = {'Message': 'GetIdentity',
+              'Truster': truster,
+              'Identity': key_prefix}
+    response = \
+        node.fcpPluginMessage(async=False,
+                              plugin_name="plugins.WebOfTrust.WebOfTrust",
+                              plugin_params=params)[0]
+
+    if response['Replies.Message'] == 'Error':
+        # Searching by exact public key hash, not matching.
+        raise util.Abort("No such identity '{0}'.\n".format(wot_identifier))
+
+    # There should be only one result.
+    # Depends on https://bugs.freenetproject.org/view.php?id=5729
+    return response
+
+
+def parse_name(wot_identifier):
+    """
+    Parse identifier of the forms: nick
+                                   nick@key
+                                   @key
+    Return nick, key. If a part is not given return an empty string for it.
+    """
+    split = wot_identifier.split('@', 1)
+    nickname_prefix = split[0]
+
+    key_prefix = ''
+    if len(split) == 2:
+        key_prefix = split[1]
+
+    return nickname_prefix, key_prefix