(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.