Repository: zmoog/refurbished

Issue:
CSV output option
We should add a new CSV output option for uses cases where users want the raw data to use with other tools.

here's an example:

```shell
$ rfrb it ipads --name 'iPad Air Wi-Fi 64GB ricondizionato - Celeste (quarta generazione)' --format csv
name,family,store,url,price,previous_price,savings_price,saving_percentage,model
"iPad Air Wi-Fi 64GB ricondizionato - Celeste (quarta generazione)","ipad","it","https://www.apple.com/it/shop/product/FYFQ2TY/A/iPad-Air-Wi-Fi-64GB-ricondizionato-Celeste-quarta-generazione",539.0,639.0, 100.0,0.15,"FYFQ2TY"
```

Discussion:

Patch:
diff --git a/README.md b/README.md
index d747423..9fa9355 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,14 @@ $ rfrb it macs --min-saving=300
 
 #### Output formats
 
-Refurbished supports several output formats.
+Refurbished supports several output formats:
+
+- `text`
+- `json`
+- `ndjson`
+- `csv`
+
+Here are a few examples.
 
 ##### text
 
@@ -67,6 +74,15 @@ $ rfrb it ipads --max-price 539 --format ndjson
 {"name": "iPad Air Wi-Fi 64GB ricondizionato - Celeste (quarta generazione)", "family": "ipad", "url": "https://www.apple.com/it/shop/product/FYFQ2TY/A/iPad-Air-Wi-Fi-64GB-ricondizionato-Celeste-quarta-generazione", "price": 539.0, "previous_price": 639.0, "savings_price": 100.0, "saving_percentage": 0.1564945226917058, "model": "FYFQ2TY"}
 ```
 
+##### CSV
+
+```shell
+$ rfrb it ipads --name 'iPad Air Wi-Fi 64GB' --format csv
+name,family,store,url,price,previous_price,savings_price,saving_percentage,model
+iPad Air Wi-Fi 64GB ricondizionato - Oro (terza generazione),ipad,it,https://www.apple.com/it/shop/product/FUUL2TY/A/iPad-Air-Wi-Fi-64GB-ricondizionato-Oro-terza-generazione,479.00,559.00,80.00,0.14,FUUL2TY
+iPad Air Wi-Fi 64GB ricondizionato - Celeste (quarta generazione),ipad,it,https://www.apple.com/it/shop/product/FYFQ2TY/A/iPad-Air-Wi-Fi-64GB-ricondizionato-Celeste-quarta-generazione,539.00,639.00,100.00,0.16,FYFQ2TY
+iPad Air Wi-Fi 64GB ricondizionato - Grigio siderale (quarta generazione),ipad,it,https://www.apple.com/it/shop/product/FYFM2TY/A/iPad-Air-Wi-Fi-64GB-ricondizionato-Grigio-siderale-quarta-generazione,539.00,639.00,100.00,0.16,FYFM2TY
+```
 
 ### Library
 
@@ -88,10 +104,10 @@ MacBook Pro 13,3" ricondizionato con Intel Core i5 quad-core a 2,0GHz e display
 
 ## Built With
 
-* [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/)
-* [price-parser](https://github.com/scrapinghub/price-parser)
-* [pydantic](https://pydantic-docs.helpmanual.io/)
-* [requests](https://requests.readthedocs.io/en/master/)
+- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/)
+- [price-parser](https://github.com/scrapinghub/price-parser)
+- [pydantic](https://pydantic-docs.helpmanual.io/)
+- [requests](https://requests.readthedocs.io/en/master/)
 
 ## Development
 
@@ -107,9 +123,9 @@ We use [SemVer](http://semver.org/) for versioning. For the versions available,
 
 ## Authors
 
-* **Maurizio Branca** - *Initial work* - [zmoog](https://github.com/zmoog)
-* **Yizhou "Andi" Cui** - *Improved parser* - [AndiCui](https://github.com/AndiCui)
-* **Grant** - *Dockerfile* - [Firefishy](https://github.com/Firefishy)
+- **Maurizio Branca** - *Initial work* - [zmoog](https://github.com/zmoog)
+- **Yizhou "Andi" Cui** - *Improved parser* - [AndiCui](https://github.com/AndiCui)
+- **Grant** - *Dockerfile* - [Firefishy](https://github.com/Firefishy)
 
 ## License
 
diff --git a/cli/rfrb b/cli/rfrb
index 419697d..9f2adf5 100755
--- a/cli/rfrb
+++ b/cli/rfrb
@@ -3,7 +3,7 @@ import decimal
 
 import click
 
-from refurbished import ProductNotFoundError, Store, feedback
+from refurbished import ProductNotFoundError, Store, cli, feedback
 
 
 @click.command()
@@ -63,7 +63,7 @@ def get_products(
             name=name,
         )
 
-        feedback.result(feedback.ProductsResult(products))
+        feedback.result(cli.ProductsResult(products))
 
     except ProductNotFoundError:
         # the selected procuct is not available on this store
diff --git a/refurbished/cli.py b/refurbished/cli.py
new file mode 100644
index 0000000..95efd2b
--- /dev/null
+++ b/refurbished/cli.py
@@ -0,0 +1,31 @@
+from typing import List
+
+from .model import Product
+
+
+class ProductsResult:
+    def __init__(self, values: List[Product]):
+        self.values = values
+
+    def str(self) -> str:
+        if len(self.values) == 0:
+            return "No products found"
+        out = ""
+        for p in self.values:
+            out += (
+                f"{p.previous_price} "
+                f"{p.price} "
+                f"{p.savings_price} "
+                f"({p.saving_percentage * 100}%) {p.name}\n"
+            )
+        return out
+
+    def data(self) -> List[Product]:
+        return self.values
+
+    def fieldnames(self) -> List[str]:
+        if self.values:
+            return (
+                self.values[0].__pydantic_model__.schema()["properties"].keys()
+            )
+        return []
diff --git a/refurbished/feedback.py b/refurbished/feedback.py
index 99fa117..9647c9b 100644
--- a/refurbished/feedback.py
+++ b/refurbished/feedback.py
@@ -1,11 +1,11 @@
+import csv
+import io
 import json
-from typing import List
+from dataclasses import asdict
 
 import click
 from pydantic.json import pydantic_encoder
 
-from .model import Product
-
 
 class Feedback:
     def __init__(self, format):
@@ -14,27 +14,45 @@ class Feedback:
     def echo(self, text, nl=True, err=False):
         click.echo(text, nl=nl, err=err)
 
-    def result(self, result):
+    def result(self, result) -> None:
         if self.format == "text":
             click.echo(
                 result.str(),
-                # delefate newlines to the result class
+                # delegate newlines to the result class
                 nl=False,
             )
         elif self.format == "json":
+            # assumption: entries are all pydantic dataclasses
             click.echo(
                 json.dumps(result.data(), indent=2, default=pydantic_encoder),
                 # delegate newline to json.dumps
                 nl=False,
             )
         elif self.format == "ndjson":
-            for product in result.data():
+            # assumption: entries are all pydantic dataclasses
+            for entry in result.data():
                 click.echo(
-                    json.dumps(product, default=pydantic_encoder),
+                    json.dumps(entry, default=pydantic_encoder),
                     # The newline is required by the format to separate the
                     # JSON objects
                     nl=True,
                 )
+        elif self.format == "csv":
+            entries = result.data()
+
+            # we need at least one entry to get the fieldnames from
+            # the pydantic dataclass and write the csv header
+            if not entries:
+                return
+
+            out = io.StringIO()
+            writer = csv.DictWriter(out, fieldnames=result.fieldnames())
+            writer.writeheader()
+            for entry in entries:
+                writer.writerow(asdict(entry))
+
+            # delegate newline to the csv writer
+            click.echo(out.getvalue(), nl=False)
 
 
 _current_feedback = Feedback("text")
@@ -50,24 +68,3 @@ def echo(value, nl=True, err=False):
 
 def result(values):
     _current_feedback.result(values)
-
-
-class ProductsResult:
-    def __init__(self, values: List[Product]):
-        self.values = values
-
-    def str(self) -> str:
-        if len(self.values) == 0:
-            return "No products found"
-        out = ""
-        for p in self.values:
-            out += (
-                f"{p.previous_price} "
-                f"{p.price} "
-                f"{p.savings_price} "
-                f"({p.saving_percentage * 100}%) {p.name}\n"
-            )
-        return out
-
-    def data(self):
-        return self.values
diff --git a/refurbished/model.py b/refurbished/model.py
index 7f9ea33..ebd9eff 100644
--- a/refurbished/model.py
+++ b/refurbished/model.py
@@ -1,7 +1,6 @@
 import decimal
 import re
 
-# from dataclasses import dataclass
 from pydantic.dataclasses import dataclass
 
 
diff --git a/requirements-dev.txt b/requirements-dev.txt
index e071685..49780e0 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,1 +1,1 @@
-pytest==7.1.3
+pytest==7.2.0


Test patch:
diff --git a/tests/test_feedback.py b/tests/test_feedback.py
index 8c0c158..21695cf 100644
--- a/tests/test_feedback.py
+++ b/tests/test_feedback.py
@@ -83,3 +83,21 @@ class TestFeedback(object):
             result.output
             == '{"name": "iPad Wi-Fi 128GB ricondizionato - Argento (sesta generazione)", "family": "ipad", "store": "it", "url": "https://www.apple.com/it/shop/product/FR7K2TY/A/Refurbished-iPad-Wi-Fi-128GB-Silver-6th-Generation", "price": 389.0, "previous_price": 449.0, "savings_price": 60.0, "saving_percentage": 0.133630289532294, "model": "FR7K2TY"}\n'  # noqa: E501
         )
+
+    @patch(
+        "requests.Session.get",
+        side_effect=ResponseBuilder("it_ipad.html"),
+    )
+    def test_csv_format(self, _, cli_runner: CliRunner):
+        result = cli_runner.invoke(
+            rfrb.get_products,
+            ["it", "ipads", "--max-price", "389", "--format", "csv"],
+        )
+
+        assert result.exit_code == 0
+        assert (
+            result.output
+            == """name,family,store,url,price,previous_price,savings_price,saving_percentage,model
+iPad Wi-Fi 128GB ricondizionato - Argento (sesta generazione),ipad,it,https://www.apple.com/it/shop/product/FR7K2TY/A/Refurbished-iPad-Wi-Fi-128GB-Silver-6th-Generation,389.00,449.00,60.00,0.133630289532294,FR7K2TY
+"""  # noqa: E501
+        )