infocalypse
 
(Arne Babenhauserheide)
2013-07-02: merge changes from operhiem1 with some comments.

merge changes from operhiem1 with some comments.

diff --git a/Readme.txt b/Readme.txt
--- a/Readme.txt
+++ b/Readme.txt
@@ -14,3 +14,15 @@ and many other features.
 And it works transparently: To publish, just clone to freenet:
 
     hg clone REPO freenet://USK@/REPO
+
+It supports WoT too:
+
+    hg clone REPO freenet://wot_nick@public_key_hash/repo
+
+    - Push / pull by name
+    - Pull requests
+
+Dependencies for full WoT support:
+    - PyYAML http://pyyaml.org/wiki/PyYAMLDocumentation
+    - lib-pyFreenet https://github.com/freenet/lib-pyFreenet-official
+    - DefusedXML https://pypi.python.org/pypi/defusedxml/
\ No newline at end of file
diff --git a/infocalypse/__init__.py b/infocalypse/__init__.py
--- a/infocalypse/__init__.py
+++ b/infocalypse/__init__.py
@@ -390,9 +390,11 @@ cmdtable = {
                 "[options]"),
 
     "fn-pull-request": (infocalypse_pull_request,
-                        WOT_OPTS +
-                        FCP_OPTS,
-                        "--wot id@key/repo"),
+                        [('', 'wot', '', 'WoT nick@key/repo to send request '
+                                         'to')]
+                        + WOT_OPTS
+                        + FCP_OPTS,
+                        "[--truster nick@key] --wot nick@key/repo"),
 
     "fn-push": (infocalypse_push,
                 [('', 'uri', '', 'insert URI to push to'),
@@ -692,9 +694,16 @@ def freenetclone(orig, *args, **opts):
     # check whether to create, pull or copy
     pulluri, pushuri = None, None
     if isfreenetpath(source):
-        pulluri = freenetpathtouri(source)
+        pulluri = freenetpathtouri(ui, source)
+
+    if not pulluri:
+        raise util.Abort()
+
     if isfreenetpath(dest):
-        pushuri = freenetpathtouri(dest)
+        pushuri = freenetpathtouri(ui, dest, pull=False)
+
+    if not pushuri:
+        return
 
     # decide which infocalypse command to use.
     if pulluri and pushuri:
diff --git a/infocalypse/commands.py b/infocalypse/commands.py
--- a/infocalypse/commands.py
+++ b/infocalypse/commands.py
@@ -107,9 +107,9 @@ def infocalypse_create(ui_, repo, **opts
     params['INSERT_URI'] = insert_uri
     inserted_to = execute_create(ui_, repo, params, stored_cfg)
 
-    # TODO: Move into some function. How to separate local success context?
-    if inserted_to is not None and attributes is not None and \
-       stored_cfg.has_wot_identity(stored_cfg.get_request_uri(repo.root)):
+    if inserted_to and opts['wot']:
+        # 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'])
 
@@ -213,10 +213,15 @@ def infocalypse_pull(ui_, repo, **opts):
 
 
 def infocalypse_pull_request(ui, repo, **opts):
+    import wot
     if not opts['wot']:
-        ui.warning("Who do you want to send the pull request to? Set --wot.")
+        ui.warn("Who do you want to send the pull request to? Set --wot.\n")
         return
 
+    wot_id, repo_name = opts['wot'].split('/', 1)
+    wot.send_pull_request(ui, repo, get_truster(ui, repo, opts), wot_id,
+                          repo_name)
+
 
 def infocalypse_push(ui_, repo, **opts):
     """ Push to an Infocalypse repository in Freenet. """
diff --git a/infocalypse/config.py b/infocalypse/config.py
--- a/infocalypse/config.py
+++ b/infocalypse/config.py
@@ -136,7 +136,14 @@ class Config:
         self.file_name = None
 
         # Use a dict instead of members to avoid pylint R0902.
-        self.defaults = {} # REDFLAG: Why is this called defaults? BAD NAME
+
+        # REDFLAG: Why is this called defaults? BAD NAME. Perhaps 'values'? 
+        # That would conflict with the .values() method of
+        # dictionaries. config.config sounds OK, I think (though it is
+        # a bit awkward to have the same name twice. But that is also
+        # done at other places, IIRC, so it should not be too
+        # surprising).
+        self.defaults = {}
         self.defaults['HOST'] = '127.0.0.1'
         self.defaults['PORT'] = 9481
         self.defaults['TMP_DIR'] = None
diff --git a/infocalypse/infcmds.py b/infocalypse/infcmds.py
--- a/infocalypse/infcmds.py
+++ b/infocalypse/infcmds.py
@@ -495,8 +495,13 @@ def is_redundant(uri):
 ############################################################
 # User feedback? success, failure?
 def execute_create(ui_, repo, params, stored_cfg):
-    """ Run the create command. """
+    """
+    Run the create command.
+
+    Return the request URI on success, and None on failure.
+    """
     update_sm = None
+    inserted_to = None
     try:
         update_sm = setup(ui_, repo, params, stored_cfg)
         # REDFLAG: Do better.
@@ -516,9 +521,8 @@ def execute_create(ui_, repo, params, st
         run_until_quiescent(update_sm, params['POLL_SECS'])
 
         if update_sm.get_state(QUIESCENT).arrived_from(((FINISHING,))):
-            ui_.status("Inserted to:\n%s\n" %
-                       '\n'.join(update_sm.get_state(INSERTING_URI).
-                                 get_request_uris()))
+            inserted_to = update_sm.get_state(INSERTING_URI).get_request_uris()
+            ui_.status("Inserted to:\n%s\n" % '\n'.join(inserted_to))
         else:
             ui_.status("Create failed.\n")
 
@@ -526,6 +530,8 @@ def execute_create(ui_, repo, params, st
     finally:
         cleanup(update_sm)
 
+    return inserted_to
+
 # REDFLAG: LATER: make this work without a repo?
 def execute_copy(ui_, repo, params, stored_cfg):
     """ Run the copy command. """
diff --git a/infocalypse/wot.py b/infocalypse/wot.py
--- a/infocalypse/wot.py
+++ b/infocalypse/wot.py
@@ -7,38 +7,133 @@ 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
 
+FREEMAIL_SMTP_PORT = 4025
+FREEMAIL_IMAP_PORT = 4143
+PULL_REQUEST_PREFIX = "[vcs] "
 
-def send_pull_request(ui, from_identity, to_identity):
-    local_identity = resolve_local_identity(ui, from_identity)
-    target_identity = resolve_identity(ui, from_identity, to_identity)
 
-    if local_identity is None or target_identity is None:
-        # Error.
+def send_pull_request(ui, repo, from_identifier, to_identifier, to_repo_name):
+    local_identity = resolve_local_identity(ui, from_identifier)
+    if local_identity is None:
+        return
+
+    target_identity = resolve_identity(ui, local_identity['Identity'],
+                                       to_identifier)
+    if target_identity is None:
         return
 
     from_address = to_freemail_address(local_identity)
-    to_address = to_freemail_address(to_identity)
+    to_address = to_freemail_address(target_identity)
 
     if from_address is None or to_address is None:
         if from_address is None:
-            ui.warn("{0} is not using Freemail.\n".format(from_identity[
-                    'Nickname']))
+            ui.warn("{0}@{1} is not using Freemail.\n".format(local_identity[
+                    'Nickname'], local_identity['Identity']))
         if to_address is None:
-            ui.warn("{0} is not using Freemail.\n".format(to_identity[
-                    'Nickname']))
+            ui.warn("{0}@{1} is not using Freemail.\n".format(target_identity[
+                    'Nickname'], target_identity['Identity']))
         return
 
-    # TODO: Use FCP host; default port.
-    smtp = smtplib.SMTP()
-    # TODO: Where to configure Freemail password?
-    smtp.login(from_address, )
-    smtp.sendmail()
+    # Check that a password is set.
+    cfg = Config.from_ui(ui)
+    password = cfg.get_freemail_password(local_identity['Identity'])
 
+    if password is None:
+        ui.warn("{0} does not have a Freemail password set.\n"
+                "Run hg fn-setupfreemail --truster {0}@{1}\n"
+                .format(local_identity['Nickname'], local_identity['Identity']))
+        return
+
+    to_repo = find_repo(ui, local_identity['Identity'], to_identifier,
+                        to_repo_name)
+
+    # TODO: Frequently doing {0}@{1} ... would a WoTIdentity class make sense?
+    if to_repo is None:
+        ui.warn("{0}@{1} has not published a repository named '{2}'.\n"
+                .format(target_identity['Nickname'],
+                        target_identity['Identifier'],
+                        to_repo_name))
+        return
+
+    repo_context = repo['tip']
+    # TODO: Will there always be a request URI set in the config? What about
+    # a path? The repo could be missing a request URI, if that URI is
+    # set manually. We could check whether the default path is a
+    # freenet path. We cannot be sure whether the request uri will
+    # always be the uri we want to send the pull-request to, though:
+    # It might be an URI we used to get some changes which we now want
+    # to send back to the maintainer of the canonical repo.
+    from_uri = cfg.get_request_uri(repo.root)
+    from_branch = repo_context.branch()
+
+    # Use double-quoted scalars so that Unicode can be included. (Nicknames.)
+    footer = yaml.dump({'request': 'pull',
+                        'vcs': 'Infocalypse',
+                        'source': from_uri + '#' + from_branch,
+                        'target': to_repo}, default_style='"',
+                       explicit_start=True, explicit_end=True,
+                       allow_unicode=True)
+
+    # TODO: Break config sanity check and sending apart so that further
+    # things can check config, prompt for whatever, then send.
+
+    source_text = ui.edit("""
+
+HG: Enter pull request message here. Lines beginning with 'HG:' are removed.
+HG: The first line has "{0}" added before it and is the email subject.
+HG: The second line should be blank.
+HG: Following lines are the body of the message.
+HG: Below is the machine-readable footer describing the request. Modifying it
+HG: or putting things below it has the potential to cause problems.
+
+{1}
+""".format(PULL_REQUEST_PREFIX, footer), from_identifier)
+    # TODO: Abort in the case of a blank message?
+    # Markdown support would be on receiving end. Maybe CLI preview eventually.
+    # (Would that even work?)
+    # TODO: Save message and load later in case sending fails.
+
+    # TODO: What if the editor uses different line endings? How to slice
+    # by-line? Just use .splitlines()
+    source_lines = source_text.split('\n')
+
+    # Body is third line and after.
+    msg = MIMEText('\n'.join(source_lines[2:]))
+    msg['Subject'] = PULL_REQUEST_PREFIX + source_lines[0]
+    msg['To'] = to_address
+    msg['From'] = from_address
+
+    smtp = smtplib.SMTP(cfg.defaults['HOST'], FREEMAIL_SMTP_PORT)
+    smtp.login(from_address, password)
+    # TODO: Catch exceptions and give nice error messages.
+    smtp.sendmail(from_address, to_address, msg.as_string())
+
+
+def receive_pull_requests(ui):
+    # TODO: Terminology - send/receive different from resolve/read elsewhere.
+    # TODO: How to find YAML? Look from end backwards for "---\n" then forward
+    # from there for "...\n"? Yepp, that should be the simplest way. If the 
+    # end is ... (without linebreak) that could be a user-error which we might 
+    # accept: It is valid: http://www.yaml.org/spec/1.2/spec.html#id2760395
+    # TODO: How to match local repo with the "target" URI? Based on repo list.
+    # all keys which have an insert key for this path would be pull-targets
+    # to check. Maybe retrieve the insert keys and invert them to get the 
+    # request keys (or can we just use the insert keys to query Freemail?).
+
+    cfg = Config.from_ui(ui)
+    imap = imaplib.IMAP4(cfg.defaults['HOST'], FREEMAIL_IMAP_PORT)
+
+    type, message_numbers = imap.search(None, "SUBJECT", PULL_REQUEST_PREFIX)
+    print(type, message_numbers)
 
 def update_repo_listing(ui, for_identity):
     # TODO: WoT property containing edition. Used when requesting.
     config = Config.from_ui(ui)
+    # Version number to support possible format changes.
     root = ET.Element('vcs', {'version': '0'})
 
     # Add request URIs associated with the given identity.
@@ -417,14 +512,34 @@ def read_identity(message, id_num):
     # depend on and would allow just returning all properties for the identity.
     #property_prefix = "Replies.Properties{0}".format(id_num)
 
-    # Add contexts for the identity too.
+    # Add contexts and other properties.
     # TODO: Unflattening WoT response? Several places check for prefix like
     # this.
-    prefix = "Replies.Contexts{0}.Context".format(id_num)
+    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(prefix):
-            num = key[len(prefix):]
+        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