Repository: rhayes777/PyAutoFit (python)

Issue:
Add database.info file
Once we have a database.sqlite file, we have no understanding of its contents. Investigating its contents requires 
us to probe it via .sqlite.

A `database.sqlite` file should have an associated `database.info` file, which lists the contents of the
dataabse in an concise and informative way.

I would suggest a .csv with the following format:

```
entry number                         unique_id                           name                    unique_tag                     total_free_parameters                       finished
           0  a14161122b532586a68b69acdf10895c                 search_name[1]         dataset_tag_example_0                                         3                           True
           1  erfopiu7n9837nr9e9fnef0980e8fn0e                 search_name[1]         dataset_tag_example_1                                         3                           True
```

If we build a database from an output folder via `scrape` this info file should be created.

If the database is created via a session this info file should constantly be being updated.

Pull request description:
feature/database info
Resolves #770

Whenever database session is committed info is also written describing each fit in the database


Patch:
diff --git a/autofit/database/aggregator/aggregator.py b/autofit/database/aggregator/aggregator.py
index 37fd75cdd..a61cf58df 100644
--- a/autofit/database/aggregator/aggregator.py
+++ b/autofit/database/aggregator/aggregator.py
@@ -5,6 +5,7 @@ from typing import Optional, List, Union, cast
 from ..sqlalchemy_ import sa
 
 from autofit.database import query as q
+from autofit.database.aggregator.info import Info
 from .scrape import Scraper
 from autofit.database import model as m
 from ..query.query import AbstractQuery, Attribute
@@ -420,6 +421,7 @@ class Aggregator(AbstractAggregator):
 
         if auto_commit:
             self.session.commit()
+            Info(self.session).write()
 
     @classmethod
     def from_database(
diff --git a/autofit/database/aggregator/info.py b/autofit/database/aggregator/info.py
new file mode 100644
index 000000000..d48edba17
--- /dev/null
+++ b/autofit/database/aggregator/info.py
@@ -0,0 +1,80 @@
+from pathlib import Path
+from typing import List
+import logging
+
+from sqlalchemy.orm import Session
+
+from autofit.database.model.fit import Fit
+from autofit.text.formatter import write_table
+
+logger = logging.getLogger(__name__)
+
+
+class Info:
+    def __init__(self, session: Session):
+        """
+        Write info describing the contents of a database file
+        in terms of the fits it contains.
+
+        Parameters
+        ----------
+        session
+            A database session
+        """
+        self.session = session
+
+    @property
+    def fits(self) -> List[Fit]:
+        """
+        A list of objects representing all the fits in the database.
+        """
+        return self.session.query(Fit).all()
+
+    @property
+    def headers(self) -> List[str]:
+        """
+        A list of strings describing the columns of the table.
+        """
+        return [
+            "unique_id",
+            "name",
+            "unique_tag",
+            "total_free_parameters",
+            "is_complete",
+        ]
+
+    @property
+    def rows(self) -> List[List[str]]:
+        """
+        A list of lists of strings describing the rows of the table.
+        """
+        return [
+            [
+                fit.id,
+                fit.name,
+                fit.unique_tag,
+                fit.total_parameters,
+                fit.is_complete,
+            ]
+            for fit in self.fits
+        ]
+
+    @property
+    def path(self) -> Path:
+        """
+        The path to the file where the info will be written.
+        """
+        return Path(self.session.bind.url.database).with_suffix(".info")
+
+    def write(self):
+        """
+        Write the info to a file.
+        """
+        try:
+            write_table(
+                self.headers,
+                self.rows,
+                str(self.path),
+            )
+        except TypeError:
+            logger.warning("Failed to write database info.")
diff --git a/autofit/database/model/fit.py b/autofit/database/model/fit.py
index cf15a8035..7978eee0c 100644
--- a/autofit/database/model/fit.py
+++ b/autofit/database/model/fit.py
@@ -199,6 +199,10 @@ class Fit(Base):
     def named_instances(self):
         return NamedInstancesWrapper(self)
 
+    @property
+    def total_parameters(self):
+        return self.model.prior_count if self.model else 0
+
     _info: List[Info] = sa.orm.relationship("Info")
 
     def __init__(self, **kwargs):
diff --git a/autofit/non_linear/paths/database.py b/autofit/non_linear/paths/database.py
index 843441487..2de5fa317 100644
--- a/autofit/non_linear/paths/database.py
+++ b/autofit/non_linear/paths/database.py
@@ -8,6 +8,7 @@ import numpy as np
 
 from autofit.database.model import Fit
 from autoconf.dictable import to_dict
+from autofit.database.aggregator.info import Info
 
 
 class DatabasePaths(AbstractPaths):
@@ -107,6 +108,7 @@ class DatabasePaths(AbstractPaths):
         Remove files from both the symlinked folder and the output directory
         """
         self.session.commit()
+        Info(self.session).write()
 
         if self.remove_files:
             shutil.rmtree(self.output_path, ignore_errors=True)
@@ -287,3 +289,4 @@ class DatabasePaths(AbstractPaths):
         self.save_json("model", to_dict(self.model))
 
         self.session.commit()
+        Info(self.session).write()
diff --git a/autofit/text/formatter.py b/autofit/text/formatter.py
index 1689ec00b..52d335ee3 100644
--- a/autofit/text/formatter.py
+++ b/autofit/text/formatter.py
@@ -241,8 +241,8 @@ def write_table(headers, rows, filename: str):
         def write_row(row_):
             writer.writerow(
                 [
-                    "{0:>{1}}".format(header, width)
-                    for width, header in zip(column_max_widths, row_)
+                    "{0:>{1}}".format("" if value is None else str(value), width)
+                    for width, value in zip(column_max_widths, row_)
                 ]
             )
 


Test patch:
diff --git a/test_autofit/aggregator/test_reference.py b/test_autofit/aggregator/test_reference.py
index 17f4397a4..0ff155d24 100644
--- a/test_autofit/aggregator/test_reference.py
+++ b/test_autofit/aggregator/test_reference.py
@@ -1,9 +1,12 @@
+import os
+
 import pytest
 
 from autoconf.class_path import get_class_path
 from autofit.aggregator import Aggregator
 from pathlib import Path
 import autofit as af
+from autofit.database.aggregator.info import Info
 
 
 @pytest.fixture(name="directory")
@@ -27,15 +30,63 @@ def test_with():
     assert model.cls is af.Exponential
 
 
-def test_database(session, directory):
-    aggregator = af.Aggregator(session)
+@pytest.fixture(name="database_path")
+def database_path(output_directory):
+    database_path = output_directory / "database.sqlite"
+    yield database_path
+    os.remove(database_path)
+
+
+@pytest.fixture(name="database_aggregator")
+def database_aggregator(
+    directory,
+    database_path,
+):
+    aggregator = af.Aggregator.from_database(
+        database_path,
+    )
     aggregator.add_directory(
         directory,
         reference={"": get_class_path(af.Exponential)},
     )
 
-    session.commit()
+    return aggregator
 
-    fit = list(aggregator)[0]
+
+def test_database(database_aggregator):
+    fit = list(database_aggregator)[0]
     model = fit.model
     assert model.cls is af.Exponential
+
+
+@pytest.fixture(name="info")
+def make_info(database_aggregator):
+    return Info(database_aggregator.session)
+
+
+def test_query_fits(info):
+    fits = info.fits
+    assert len(info.fits) == 3
+    assert fits[0].total_parameters == 4
+
+
+def test_headers_and_rows(info):
+    assert len(info.headers) == len(info.rows[0])
+
+
+def test_info_path(info, output_directory):
+    assert info.path == output_directory / "database.info"
+
+
+def test_database_info(
+    database_aggregator,
+    output_directory,
+):
+    assert (
+        (output_directory / "database.info").read_text()
+        == """                         unique_id,name,unique_tag,total_free_parameters,is_complete
+  c4bf344d706947aa66b129ed2e05e1bd,    ,          ,                    4,      False
+c4bf344d706947aa66b129ed2e05e1bd_0,    ,          ,                    0,           
+c4bf344d706947aa66b129ed2e05e1bd_1,    ,          ,                    0,           
+"""
+    )