From 3300af22d653bb22368e6eaee8f5e3bb4f90dec5 Mon Sep 17 00:00:00 2001
From: Moul <moul@moul.re>
Date: Wed, 16 Mar 2022 08:10:07 +0100
Subject: [PATCH] [feat] #134: Add ability to pass a file containing the tx
 recipients and amounts

Introduce function to parse the file and compute the values

Comments (#) are ignored
ABSOLUTE/RELATIVE header required to specify the reference
of the specified amounts

Set recipients argument to no longer required, add a check instead
Set file mutuality exclusive with amounts(UD), allsources, and recipients
Update tx cli tests

Add tests
---
 silkaj/tx.py          | 95 +++++++++++++++++++++++++++++++++++--------
 tests/test_tx.py      | 12 +++---
 tests/test_tx_file.py | 79 +++++++++++++++++++++++++++++++++++
 3 files changed, 164 insertions(+), 22 deletions(-)
 create mode 100644 tests/test_tx_file.py

diff --git a/silkaj/tx.py b/silkaj/tx.py
index 685806f3..65702fa8 100644
--- a/silkaj/tx.py
+++ b/silkaj/tx.py
@@ -13,8 +13,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with Silkaj. If not, see <https://www.gnu.org/licenses/>.
 
+
 import math
+import shlex
+import sys
 from re import compile, search
+from typing import List
 
 import click
 from duniterpy.api.bma.tx import process
@@ -60,7 +64,7 @@ NBR_ISSUERS = 1
     type=click.FloatRange(MINIMAL_ABSOLUTE_TX_AMOUNT),
     help=f"Quantitative amount(s):\n-a <amount>\nMinimum amount is {MINIMAL_ABSOLUTE_TX_AMOUNT}.",
     cls=cli_tools.MutuallyExclusiveOption,
-    mutually_exclusive=["amountsud", "allsources"],
+    mutually_exclusive=["amountsud", "allsources", "file_path"],
 )
 @click.option(
     "amountsud",
@@ -70,25 +74,34 @@ NBR_ISSUERS = 1
     type=click.FloatRange(MINIMAL_RELATIVE_TX_AMOUNT),
     help=f"Relative amount(s):\n-d <amount_UD>\nMinimum amount is {MINIMAL_RELATIVE_TX_AMOUNT}",
     cls=cli_tools.MutuallyExclusiveOption,
-    mutually_exclusive=["amounts", "allsources"],
+    mutually_exclusive=["amounts", "allsources", "file_path"],
 )
 @click.option(
     "--allSources",
     is_flag=True,
     help="Send all sources to one recipient",
     cls=cli_tools.MutuallyExclusiveOption,
-    mutually_exclusive=["amounts", "amountsud"],
+    mutually_exclusive=["amounts", "amountsud", "file_path"],
 )
 @click.option(
     "recipients",
     "--recipient",
     "-r",
     multiple=True,
-    required=True,
     help="Pubkey(s)’ recipients + optional checksum:\n-r <pubkey>[:checksum]\n\
 Sending to many recipients is possible:\n\
 * With one amount, all will receive the amount\n\
 * With many amounts (one per recipient)",
+    cls=cli_tools.MutuallyExclusiveOption,
+    mutually_exclusive=["file_path"],
+)
+@click.option(
+    "file_path",
+    "--file",
+    "-f",
+    help="File’s path containing a list of amounts in absolute or relative reference and recipients’ pubkeys",
+    cls=cli_tools.MutuallyExclusiveOption,
+    mutually_exclusive=["recipients", "amounts", "amountsUD", "allsources"],
 )
 @click.option("--comment", "-c", default="", help="Comment")
 @click.option(
@@ -103,22 +116,25 @@ def send_transaction(
     amountsud,
     allsources,
     recipients,
+    file_path,
     comment,
     outputbackchange,
     yes,
 ):
-    """
-    Main function
-    """
-    if not (amounts or amountsud or allsources):
-        tools.message_exit("Error: amount, amountUD or allSources is not set.")
-    if allsources and len(recipients) > 1:
-        tools.message_exit(
-            "Error: the --allSources option can only be used with one recipient."
-        )
-    # compute amounts and amountsud
-    if not allsources:
-        tx_amounts = transaction_amount(amounts, amountsud, recipients)
+    if file_path:
+        tx_amounts, recipients = parse_file_containing_amounts_recipients(file_path)
+    else:
+        if not (amounts or amountsud or allsources):
+            tools.message_exit("Error: amount, amountUD or allSources is not set.")
+        if not recipients:
+            tools.message_exit("Error: A recipient should be passed")
+        if allsources and len(recipients) > 1:
+            tools.message_exit(
+                "Error: the --allSources option can only be used with one recipient."
+            )
+        # compute amounts and amountsud
+        if not allsources:
+            tx_amounts = transaction_amount(amounts, amountsud, recipients)
 
     key = auth.auth_method()
     issuer_pubkey = key.pubkey
@@ -167,6 +183,53 @@ def send_transaction(
         )
 
 
+def parse_file_containing_amounts_recipients(file_path: str) -> List:
+    """
+    Parse file in a specific format
+    Comments are ignored
+    Format should be:
+    ```txt
+    [ABSOLUTE/RELATIVE]
+
+    # comment1
+    amount1 recipient1’s pubkey
+    # comment2
+    amount2 recipient2’s pubkey
+    ```
+    """
+    reference = ""
+    amounts, recipients = [], []
+    with open(file_path) as file:
+        for n, line in enumerate(file):
+            line = shlex.split(line, True)
+            if line:
+                if n == 0:
+                    reference = line[0]
+                else:
+                    try:
+                        amounts.append(float(line[0]))
+                        recipients.append(line[1])
+                    except (ValueError, IndexError):
+                        tools.message_exit(f"Syntax error at line {n + 1}")
+
+    if not reference or (reference != "ABSOLUTE" and reference != "RELATIVE"):
+        tools.message_exit(
+            f"{file_path} must contain at first line 'ABSOLUTE' or 'RELATIVE' header"
+        )
+
+    if not amounts or not recipients:
+        tools.message_exit("No amounts or recipients specified")
+
+    # Compute amount depending on the reference
+    if reference == "ABSOLUTE":
+        reference_mult = CENT_MULT_TO_UNIT
+    else:
+        reference_mult = money.UDValue().ud_value
+    tx_amounts = compute_amounts(amounts, reference_mult)
+
+    return tx_amounts, recipients
+
+
 def transaction_amount(amounts, UDs_amounts, outputAddresses):
     """
     Check that the number of passed amounts(UD) and recipients are the same
diff --git a/tests/test_tx.py b/tests/test_tx.py
index d5c90417..94464efc 100644
--- a/tests/test_tx.py
+++ b/tests/test_tx.py
@@ -135,16 +135,16 @@ def test_transaction_amount_errors(
 def test_tx_passed_amount_cli():
     """One option"""
     result = CliRunner().invoke(cli, ["tx", "--amount", "1"])
-    assert "Error: Missing option" in result.output
-    assert result.exit_code == 2
+    assert "Error: A recipient should be passed\n" in result.output
+    assert result.exit_code == 1
 
     result = CliRunner().invoke(cli, ["tx", "--amountUD", "1"])
-    assert "Error: Missing option" in result.output
-    assert result.exit_code == 2
+    assert "Error: A recipient should be passed\n" in result.output
+    assert result.exit_code == 1
 
     result = CliRunner().invoke(cli, ["tx", "--allSources"])
-    assert "Error: Missing option" in result.output
-    assert result.exit_code == 2
+    assert "Error: A recipient should be passed\n" in result.output
+    assert result.exit_code == 1
 
     """Multiple options"""
     result = CliRunner().invoke(cli, ["tx", "--amount", 1, "--amountUD", 1])
diff --git a/tests/test_tx_file.py b/tests/test_tx_file.py
new file mode 100644
index 00000000..628a7d1e
--- /dev/null
+++ b/tests/test_tx_file.py
@@ -0,0 +1,79 @@
+# Copyright  2016-2022 Maël Azimi <m.a@moul.re>
+#
+# Silkaj is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Silkaj 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Silkaj. If not, see <https://www.gnu.org/licenses/>.
+
+import pytest
+from click.testing import CliRunner
+
+from silkaj.constants import CENT_MULT_TO_UNIT
+from silkaj.money import UDValue
+from silkaj.tx import parse_file_containing_amounts_recipients
+
+FILE_PATH = "recipients.txt"
+
+ud_value = UDValue().ud_value
+
+
+@pytest.mark.parametrize(
+    "file_content, amounts_exp, recipients_exp",
+    [
+        (
+            "ABSOLUTE\n10 pubkey1\n20 pubkey2",
+            [10 * CENT_MULT_TO_UNIT, 20 * CENT_MULT_TO_UNIT],
+            ["pubkey1", "pubkey2"],
+        ),
+        (
+            "RELATIVE\n#toto\n10 pubkey1\n#titi\n20 pubkey2",
+            [10 * ud_value, 20 * ud_value],
+            ["pubkey1", "pubkey2"],
+        ),
+    ],
+)
+def test_parse_file_containing_amounts_recipients(
+    file_content, amounts_exp, recipients_exp
+):
+    runner = CliRunner()
+    with runner.isolated_filesystem():
+        with open(FILE_PATH, "w") as f:
+            f.write(file_content)
+        amounts, recipients = parse_file_containing_amounts_recipients(FILE_PATH)
+    assert amounts == amounts_exp
+    assert recipients == recipients_exp
+
+
+HEADER_ERR = (
+    "recipients.txt must contain at first line 'ABSOLUTE' or 'RELATIVE' header\n"
+)
+SYNTAX_ERR = "Syntax error at line"
+SPEC_ERR = "No amounts or recipients specified"
+
+
+@pytest.mark.parametrize(
+    "file_content, error",
+    [
+        ("ABSOLUTE\n10 pubkey1\n20", SYNTAX_ERR),
+        ("#RELATIVE\n10 pubkey1\n20 pubkey2", HEADER_ERR),
+        ("RELATIVE\npubkey1 10\n20 pubkey2", SYNTAX_ERR),
+        ("ABSOLUTE", SPEC_ERR),
+        ("", HEADER_ERR),
+    ],
+)
+def test_parse_file_containing_amounts_recipients_errors(file_content, error, capsys):
+    runner = CliRunner()
+    with runner.isolated_filesystem():
+        with open(FILE_PATH, "w") as f:
+            f.write(file_content)
+        with pytest.raises(SystemExit) as pytest_exit:
+            parse_file_containing_amounts_recipients(FILE_PATH)
+    assert error in capsys.readouterr().out
-- 
GitLab