infocalypse
 
(drak)
2013-08-16: merge

merge

diff --git a/.bugs/bugs b/.bugs/bugs
--- a/.bugs/bugs
+++ b/.bugs/bugs
@@ -13,5 +13,5 @@ Add i18n support for messages.          
 Fix implied clone destination when cloning from freenet:.    | owner:Steve Dougherty <steve@asksteved.com>, open:True, id:9bd3ef617ba8871d28fbdae2500542e93302c791, time:1375736289.27
 Unit tests involving the node are hard.                      | owner:Steve Dougherty <steve@asksteved.com>, open:True, id:b01a53e59a2096254ecacdcee7673df5323d786e, time:1375737309.25
 Consider reworking repo list XML for conciseness.            | owner:Steve Dougherty <steve@asksteved.com>, open:True, id:b511106032c58ffe7b33d0eb65bac7ec5555575e, time:1375734587.66
-simpler-wot-uris: name/repo                                  | owner:Arne Babenhauserheide <bab@draketo.de>, open:True, id:d4f2df3ca2c441e4be389be846634f5a4a08906e, time:1372232568.9
+simpler-wot-uris: name/repo                                  | owner:Arne Babenhauserheide <bab@draketo.de>, open:False, id:d4f2df3ca2c441e4be389be846634f5a4a08906e, time:1372232568.9
 Add a warning that comments and unrecognized entries in ~/.infocalypse are removed. | owner:Steve Dougherty <steve@asksteved.com>, open:True, id:e9236fdd23d44bfbf8565bb1a59317289f324fd6, time:1375735601.35
diff --git a/infocalypse/commands.py b/infocalypse/commands.py
--- a/infocalypse/commands.py
+++ b/infocalypse/commands.py
@@ -63,8 +63,8 @@ def infocalypse_create(ui_, repo, **opts
         nick_prefix, repo_name, repo_edition = opts['wot'].split('/', 2)
 
         if not repo_name.endswith('.R1') and not repo_name.endswith('.R0'):
-            ui_.warning("Warning: Creating repository without redundancy. (R0"
-                        " or R1)")
+            ui_.warn("Warning: Creating repository without redundancy. (R0 or"
+                     " R1)\n")
 
         from wot_id import Local_WoT_ID
 
@@ -238,8 +238,8 @@ def infocalypse_check_notifications(ui, 
 
 
 def infocalypse_connect(ui, repo, **opts):
-    import wot
-    wot.connect(ui, repo)
+    import plugin_connect
+    plugin_connect.connect(ui, repo)
 
 
 def infocalypse_push(ui_, repo, **opts):
diff --git a/infocalypse/config.py b/infocalypse/config.py
--- a/infocalypse/config.py
+++ b/infocalypse/config.py
@@ -128,8 +128,13 @@ class Config:
         # repo id -> publisher WoT identity
         self.wot_identities = {}
         # TODO: Should this be keyed by str(WoT_ID) ?
-        # WoT public key hash -> Freemail password
+        # WoT identity ID -> Freemail password
         self.freemail_passwords = {}
+        # WoT identity ID -> last known repo list edition.
+        # TODO: Once WoT allows setting a property without triggering an
+        # immediate insert, this can move to a WoT property. (Can then query
+        # remote identities! Easier bootstrapping than from edition 0.)
+        self.repo_list_editions = {}
         # fms_id -> (usk_hash, ...) map
         self.fmsread_trust_map = DEFAULT_TRUST.copy()
         self.fmsread_groups = DEFAULT_GROUPS
@@ -269,6 +274,23 @@ class Config:
                              "Run hg fn-setupfreemail --truster {0}\n"
                              .format(wot_identity))
 
+    def set_repo_list_edition(self, wot_identity, edition):
+        """
+        Set the repository list edition for the given WoT identity.
+        :type wot_identity: WoT_ID
+        """
+        self.repo_list_editions[wot_identity.identity_id] = edition
+
+    def get_repo_list_edition(self, wot_identity):
+        """
+        Return the repository list edition associated with the given WoT
+        identity. Return 0 if one is not set.
+        """
+        if wot_identity.identity_id in self.repo_list_editions:
+            return self.repo_list_editions[wot_identity.identity_id]
+        else:
+            return 0
+
     # Hmmm... really nescessary?
     def get_dir_insert_uri(self, repo_dir):
         """ Return the insert USK for repo_dir or None. """
@@ -383,6 +405,11 @@ class Config:
                 cfg.freemail_passwords[wot_id] = parser.get(
                     'freemail_passwords', wot_id)
 
+        if parser.has_section('repo_list_editions'):
+            for wot_id in parser.options('repo_list_editions'):
+                cfg.repo_list_editions[wot_id] = int(parser.get(
+                    'repo_list_editions', wot_id))
+
         # ignored = fms_id|usk_hash|usk_hash|...
         if parser.has_section('fmsread_trust_map'):
             cfg.fmsread_trust_map.clear() # Wipe defaults.
@@ -467,6 +494,10 @@ class Config:
         for wot_id in cfg.freemail_passwords:
             parser.set('freemail_passwords', wot_id, cfg.freemail_passwords[
                 wot_id])
+        parser.add_section('repo_list_editions')
+        for wot_id in cfg.repo_list_editions:
+            parser.set('repo_list_editions', wot_id, cfg.repo_list_editions[
+                wot_id])
         parser.add_section('fmsread_trust_map')
         for index, fms_id in enumerate(cfg.fmsread_trust_map):
             entry = cfg.fmsread_trust_map[fms_id]
diff --git a/infocalypse/keys.py b/infocalypse/keys.py
--- a/infocalypse/keys.py
+++ b/infocalypse/keys.py
@@ -18,6 +18,28 @@ class USK:
         elif self.key.startswith('freenet://'):
             self.key = self.key[len('freenet://'):]
 
+    def get_repo_name(self):
+        """
+        Return name with the redundancy level, if any, removed.
+
+        # TODO: tests. Use in detecting duplicate names. (Also
+        # determining repo names from URI.)
+
+        >>> USK('USK@.../name/5').get_repo_name()
+        'name'
+        >>> USK('USK@.../name.R1/5').get_repo_name()
+        'name'
+        >>> USK('USK@.../name.R0/5').get_repo_name()
+        'name'
+        >>> USK('USK@.../name.something/5').get_repo_name()
+        'name.something'
+        >>> USK('USK@.../name.R2/5').get_repo_name()
+        'name.R2'
+        """
+        if self.name.endswith('.R1') or self.name.endswith('.R0'):
+            return self.name[:-3]
+        return self.name
+
     def clone(self):
         return USK(str(self))
 
diff --git a/infocalypse/plugin_connect.py b/infocalypse/plugin_connect.py
new file mode 100644
--- /dev/null
+++ b/infocalypse/plugin_connect.py
@@ -0,0 +1,112 @@
+from signal import signal, SIGINT
+from time import sleep
+import fcp
+import threading
+from mercurial import util
+import sys
+
+PLUGIN_NAME = "org.freenetproject.plugin.dvcs_webui.main.Plugin"
+
+def connect(ui, repo):
+    node = fcp.FCPNode()
+
+    ui.status("Connecting.\n")
+
+    # TODO: Would it be worthwhile to have a wrapper that includes PLUGIN_NAME?
+    # TODO: Where to document the spec? devnotes.txt? How to format?
+    hi_there = node.fcpPluginMessage(plugin_name=PLUGIN_NAME,
+                                     plugin_params={'Message': 'Hello',
+                                                    'VoidQuery': 'true'})[0]
+
+    if hi_there['header'] == 'Error':
+        raise util.Abort("The DVCS web UI plugin is not loaded.")
+
+    if hi_there['Replies.Message'] == 'Error':
+        # TODO: Debugging
+        print hi_there
+        raise util.Abort("Another VCS instance is already connected.")
+
+    session_token = hi_there['Replies.SessionToken']
+
+    ui.status("Connected.\n")
+
+    def disconnect(signum, frame):
+        ui.status("Disconnecting.\n")
+        node.fcpPluginMessage(plugin_name=PLUGIN_NAME,
+                              plugin_params=
+                              {'Message': 'Disconnect',
+                               'SessionToken': session_token})
+        sys.exit()
+
+    # Send Disconnect on interrupt instead of waiting on timeout.
+    signal(SIGINT, disconnect)
+
+    def ping():
+        # Loop with delay.
+        while True:
+            pong = node.fcpPluginMessage(plugin_name=PLUGIN_NAME,
+                                         plugin_params=
+                                         {'Message': 'Ping',
+                                          'SessionToken': session_token})[0]
+            if pong['Replies.Message'] == 'Error':
+                raise util.Abort(pong['Replies.Description'])
+            elif pong['Replies.Message'] != 'Pong':
+                ui.warn("Got unrecognized Ping reply '{0}'.\n".format(pong[
+                        'Replies.Message']))
+
+            # Wait for less than timeout threshold. In testing responses take
+            # a little over a second.
+            sleep(3.5)
+
+    # Start self-perpetuating pinging in the background.
+    t = threading.Timer(0.0, ping)
+    # Daemon threads do not hold up the process exiting. Allows prompt
+    # response to - for instance - SIGTERM.
+    t.daemon = True
+    t.start()
+
+    while True:
+        query_identifier = node._getUniqueId()
+        # The event-querying is single-threaded, which makes things slow as
+        # everything waits on the completion of the current operation.
+        # Asynchronous code would require changes on the plugin side but
+        # potentially have much lower latency.
+        # TODO: Can wrap away PLUGIN_NAME, SessionToken, and QueryIdentifier?
+        command = node.fcpPluginMessage(plugin_name=PLUGIN_NAME,
+                                        plugin_params=
+                                        {'Message': 'Ready',
+                                         'SessionToken': session_token,
+                                         'QueryIdentifier': query_identifier})[0]
+
+        response = command['Replies.Message']
+        if response == 'Error':
+            raise util.Abort(command['Replies.Description'])
+
+        if response not in handlers:
+            raise util.Abort("Unsupported query '{0}'\n")
+
+        # Handlers are indexed by the query message name, take the query
+        # message, and return (result_name, plugin_params).
+        result_name, plugin_params = handlers[response](command)
+
+        plugin_params['Message'] = result_name
+        plugin_params['QueryIdentifier'] = query_identifier
+        plugin_params['SessionToken'] = session_token
+
+        ack = node.fcpPluginMessage(plugin_name=PLUGIN_NAME,
+                                    plugin_params=plugin_params)[0]
+
+        if ack['Replies.Message'] != "Ack":
+            raise util.Abort("Received unexpected message instead of result "
+                             "acknowledgement:\n{0}\n".format(ack))
+
+
+# Handlers return two items: result message name, message-specific parameters.
+# The sending code handles the plugin name, required parameters and plugin name.
+
+
+def VoidQuery(query):
+    return "VoidResult", {}
+
+# TODO: Perhaps look up method by name directly?
+handlers = {'VoidQuery': VoidQuery}
diff --git a/infocalypse/wot.py b/infocalypse/wot.py
--- a/infocalypse/wot.py
+++ b/infocalypse/wot.py
@@ -8,76 +8,16 @@ 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
 VCS_TOKEN = "[vcs]"
-PLUGIN_NAME = "org.freenetproject.plugin.dvcs_webui.main.Plugin"
 # "infocalypse" is lower case in case it is used somewhere mixed case can
 # cause problems like a filesystem path. Used for machine-readable VCS name.
 VCS_NAME = "infocalypse"
 
 
-def connect(ui, repo):
-    node = fcp.FCPNode()
-
-    # TODO: Should I be using this? Looks internal. The identifier needs to
-    # be consistent though.
-    fcp_id = node._getUniqueId()
-
-    ui.status("Connecting as '%s'.\n" % fcp_id)
-
-    def ping():
-        pong = node.fcpPluginMessage(plugin_name=PLUGIN_NAME, id=fcp_id,
-                                     plugin_params={'Message': 'Ping'})[0]
-        if pong['Replies.Message'] == 'Error':
-            raise util.Abort(pong['Replies.Description'])
-        # Must be faster than the timeout threshold. (5 seconds)
-        threading.Timer(4.0, ping).start()
-
-    # Start self-perpetuating pinging in the background.
-    t = threading.Timer(0.0, ping)
-    # Daemon threads do not hold up the process exiting. Allows prompt
-    # response to - for instance - SIGTERM.
-    t.daemon = True
-    t.start()
-
-    while True:
-        sequenceID = node._getUniqueId()
-        # The event-querying is single-threaded, which makes things slow as
-        # everything waits on the completion of the current operation.
-        # Asynchronous code would require changes on the plugin side but
-        # potentially have much lower latency.
-        command = node.fcpPluginMessage(plugin_name=PLUGIN_NAME, id=fcp_id,
-                                        plugin_params=
-                                        {'Message': 'ClearToSend',
-                                         'SequenceID': sequenceID})[0]
-        # TODO: Look up handlers in a dictionary.
-        print command
-
-        # Reload the config each time - it may have changed between messages.
-        cfg = Config.from_ui(ui)
-
-        response = command['Replies.Message']
-        if response == 'Error':
-            raise util.Abort(command['Replies.Description'])
-        elif response == 'ListLocalRepos':
-            params = {'Message': 'RepoList',
-                      'SequenceID': sequenceID}
-
-            # Request USKs are keyed by repo path.
-            repo_index = 0
-            for path in cfg.request_usks.iterkeys():
-                params['Repo%s' % repo_index] = path
-                repo_index += 1
-
-            ack = node.fcpPluginMessage(plugin_name=PLUGIN_NAME, id=fcp_id,
-                                        plugin_params=params)[0]
-            print ack
-
-
 def send_pull_request(ui, repo, from_identity, to_identity, to_repo_name):
     """
     Prompt for a pull request message, and send a pull request from
@@ -311,8 +251,10 @@ def update_repo_listing(ui, for_identity
     # TODO: Somehow store the edition, perhaps in ~/.infocalypse. WoT
     # properties are apparently not appropriate.
 
+    cfg = Config.from_ui(ui)
+
     insert_uri.name = 'vcs'
-    insert_uri.edition = '0'
+    insert_uri.edition = cfg.get_repo_list_edition(for_identity)
 
     ui.status("Inserting with URI:\n{0}\n".format(insert_uri))
     uri = node.put(uri=str(insert_uri), mimetype='application/xml',
@@ -322,6 +264,8 @@ def update_repo_listing(ui, for_identity
         ui.warn("Failed to update repository listing.")
     else:
         ui.status("Updated repository listing:\n{0}\n".format(uri))
+        cfg.set_repo_list_edition(for_identity, USK(uri).edition)
+        Config.to_file(cfg)
 
 
 def build_repo_list(ui, for_identity):
@@ -369,32 +313,85 @@ def read_repo_listing(ui, identity):
 
     :type identity: WoT_ID
     """
+    cfg = Config.from_ui(ui)
     uri = identity.request_uri.clone()
     uri.name = 'vcs'
-    uri.edition = 0
+    uri.edition = cfg.get_repo_list_edition(identity)
 
     # TODO: Set and read vcs edition property.
-    node = fcp.FCPNode()
-    ui.status("Fetching {0}\n".format(uri))
-    # TODO: What exception can this throw on failure? Catch it,
-    # print its description, and return None.
-    mime_type, repo_xml, msg = node.get(str(uri), priority=1,
-                                        followRedirect=True)
+    ui.status("Fetching.\n")
+    mime_type, repo_xml, msg = fetch_edition(uri)
+    ui.status("Fetched {0}.\n".format(uri))
 
-    ui.status("Parsing.\n")
+    cfg.set_repo_list_edition(identity, uri.edition)
+    Config.to_file(cfg)
+
     repositories = {}
+    ambiguous = []
     root = fromstring(repo_xml)
     for repository in root.iterfind('repository'):
         if repository.get('vcs') == VCS_NAME:
-            uri = repository.text
-            # Expecting key/reponame.R<num>/edition
-            name = uri.split('/')[1].split('.')[0]
-            ui.status("Found repository \"{0}\" at {1}\n".format(name, uri))
-            repositories[name] = uri
+            uri = USK(repository.text)
+            name = uri.get_repo_name()
+            if name not in repositories:
+                repositories[name] = uri
+            else:
+                existing = repositories[name]
+                if uri.key == existing.key and uri.name == existing.name:
+                    # Different edition of same key and complete name.
+                    # Use the latest edition.
+                    if uri.edition > existing.edition:
+                        repositories[name] = uri
+                else:
+                    # Different key or complete name. Later remove and give
+                    # warning.
+                    ambiguous.append(name)
+
+    for name in ambiguous:
+        # Same repo name but different key or exact name.
+        ui.warn("\"{0}\" refers ambiguously to multiple paths. Ignoring.\n"
+                .format(name))
+        del repositories[name]
+
+    # TODO: Would it make sense to mention those for which multiple editions
+    # are specified? It has no practical impact from this perspective,
+    # and these problems should be pointed out (or prevented) for local repo
+    # lists.
+
+    for name in repositories.iterkeys():
+        ui.status("Found repository \"{0}\".\n".format(name))
+
+    # Convert values from USKs to strings - USKs are not expected elsewhere.
+    for key in repositories.keys():
+        repositories[key] = str(repositories[key])
 
     return repositories
 
 
+def fetch_edition(uri):
+    """
+    Fetch a USK uri, following redirects. Change the uri edition to the one
+    fetched.
+    :type uri: USK
+    """
+    node = fcp.FCPNode()
+    # Following a redirect automatically does not provide the edition used,
+    # so manually following redirects is required.
+    # TODO: Is there ever legitimately more than one redirect?
+    try:
+        return node.get(str(uri), priority=1)
+    except fcp.FCPGetFailed, e:
+        # Error code 27 is permanent redirect: there's a newer edition of
+        # the USK.
+        # https://wiki.freenetproject.org/FCPv2/GetFailed#Fetch_Error_Codes
+        if not e.info['Code'] == 27:
+            raise
+
+        uri.edition = USK(e.info['RedirectURI']).edition
+
+        return node.get(str(uri), priority=1)
+
+
 def resolve_pull_uri(ui, path, truster):
         """
         Return a pull URI for the given path.