coveralls: ## Pull Request Test Coverage Report for [Build 39](https://coveralls.io/builds/30487743)

* **12** of **17**   **(70.59%)**  changed or added relevant lines in **2** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.4%**) to **82.743%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [swn_database/SQLDatabaseLink.py](https://coveralls.io/builds/30487743/source?filename=swn_database%2FSQLDatabaseLink.py#L37) | 3 | 8 | 37.5%
<!-- | **Total:** | **12** | **17** | **70.59%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/30487743/badge)](https://coveralls.io/builds/30487743) |
| :-- | --: |
| Change from base [Build 31](https://coveralls.io/builds/30407245): |  -0.4% |
| Covered Lines: | 374 |
| Relevant Lines: | 452 |

---
##### 💛  - [Coveralls](https://coveralls.io)

Robtom5: Drop in coverage acceptable as SQLDatabaseLink not tested yet
diff --git a/swn_database/SQLDatabaseLink.py b/swn_database/SQLDatabaseLink.py
index 979e026..ac1f82c 100644
--- a/swn_database/SQLDatabaseLink.py
+++ b/swn_database/SQLDatabaseLink.py
@@ -24,7 +24,7 @@ class SQLDatabaseLink(object):
             self.conn.close()
             self.conn = None
 
-    def execute_query(self, query, verbose=False):
+    def execute_query(self, query, verbose=False, suppress=False):
         if self.conn is None:
             raise Error("Must connect to database first")
         cursor = self.conn.cursor()
@@ -34,9 +34,10 @@ class SQLDatabaseLink(object):
             if verbose:
                 print("Query executed successfully")
         except sqlite3.Error as e:
-            print(f"The error '{e}' occured")
+            if not suppress:
+                print(f"The error '{e}' occured")
 
-    def execute_read_query(self, query):
+    def execute_read_query(self, query, suppress=False):
         if self.conn is None:
             raise Error("Must connect to database first")
         cursor = self.conn.cursor()
@@ -47,4 +48,8 @@ class SQLDatabaseLink(object):
             result = cursor.fetchall()
             return result
         except sqlite3.Error as e:
-            print(f"The error '{e}' occured")
+            if not suppress:
+                print(f"The error '{e}' occured")
+
+    def commit(self):
+        self.conn.commit()
diff --git a/swn_database/converters/ConnectionConverter.py b/swn_database/converters/ConnectionConverter.py
index 4af0c91..7e655dc 100644
--- a/swn_database/converters/ConnectionConverter.py
+++ b/swn_database/converters/ConnectionConverter.py
@@ -29,8 +29,8 @@ class ConnectionConverter():
                 start_hex,
                 end_hex
             ) VALUES (
-                '{start_hex}',
-                '{end_hex}'
+                '{start_hex.upper()}',
+                '{end_hex.upper()}'
             );
             SELECT * FROM {self.table_name} 
             WHERE start_hex='{start_hex}' AND end_hex='{end_hex}'"""
diff --git a/swn_database/converters/PlanetConverter.py b/swn_database/converters/PlanetConverter.py
index 9bccf42..9664a7e 100644
--- a/swn_database/converters/PlanetConverter.py
+++ b/swn_database/converters/PlanetConverter.py
@@ -153,11 +153,15 @@ class PlanetConverter():
 
     def load_by_id(self, ID):
         query_res = self.sql_link.execute_read_query(f"SELECT * FROM {self.table_name} WHERE planet_id = {ID}")
-        return Planet.deserialize(query_res)
+        return Planet.deserialize(query_res[0])
 
     def load_by_name(self, name):
-        query_res = self.sql_link.execute_read_query(f"SELECT * FROM {self.table_name} WHERE name = {name}")
-        return Planet.deserialize(query_res)
+        query_res = self.sql_link.execute_read_query(f"SELECT * FROM {self.table_name} WHERE name = '{name}'")
+        return Planet.deserialize(query_res[0])
 
-    def load_all(self):
-        return [Planet.deserialize(res) for res in self.sql_link.execute_read_query(f"SELECT * FROM {self.table_name}")]
+    def load_all(self, order = None):
+        query = f"SELECT * FROM {self.table_name}"
+        if order is not None:
+            ordering = f" ORDER BY {order} ASC"
+            query = query + ordering
+        return [Planet.deserialize(res) for res in self.sql_link.execute_read_query(query)]
diff --git a/tools/planet_manager.py b/tools/planet_manager.py
new file mode 100644
index 0000000..4ea9a53
--- /dev/null
+++ b/tools/planet_manager.py
@@ -0,0 +1,251 @@
+#! /usr/bin/env python3
+import cmd
+import math
+from swn_database.data import Planet, Coordinate, Connection
+from swn_database import SQLDatabaseLink
+from swn_database.converters import PlanetConverter, ConnectionConverter
+
+
+class Converters():
+    def __init__(self, link):
+        self.planet = PlanetConverter(link)
+        self.connection = ConnectionConverter(link)
+
+
+ATMOSPHERES = dict(
+    [(2, "Corrosive"),
+     (3, "Inert, useless for respiration."),
+     (4, "Airless/Thin")] +
+    [(n, "Breathable") for n in range(5, 10)] +
+    [(10, "Thick, requires a pressure mask."),
+     (11, "Invasive"),
+     (12, "Corrosive & Invasive")])
+
+TEMPERATURES = dict(
+    [(2, "Frozen, surface locked in perpetual ice. Atmosphere frozen into solid oxygen and lakes of liquid helium."),
+     (3, "Cold, surface dominated by glaciers and tundra.")] +
+    [(n, "Variable Cold") for n in range(4, 6)] +
+    [(n, "Temperate") for n in range(6, 9)] +
+    [(n, "Variable Warm") for n in range(9, 11)] +
+    [(11, "Warm, surface predominantly tropical/desert with hotter areas."),
+     (12, "Burning, surface inhospitable to human life with vacc suit or equivalent.")])
+
+BIOSPHERES = dict(
+    [(2, "Remnant, only the wreckage of a ruined biosphere remains."),
+     (3, "Microbial, litte more that microbial life and the occasional slime mold exists.")] +
+    [(n, "No native biosphere present.") for n in range(3, 6)] +
+    [(n, "Human-Miscible") for n in range(6, 9)] +
+    [(n, "Immiscible") for n in range(9, 11)] +
+    [(11, "Hybrid"),
+     (12, "Engineered")])
+
+POPULATIONS = dict(
+    [(2, "Failed Colony"),
+     (3, "Outpost, few hundred to few thousand inhabitants")] +
+    [(n, "Fewer than a million inhabitants") for n in range(4, 6)] +
+    [(n, "Several million inhabitants") for n in range(6, 9)] +
+    [(n, "Hundreds of millions of inhabitants") for n in range(9, 11)] +
+    [(11, "Billions of inhabitants"),
+     (12, "Alien inhabitants")])
+
+TECHLEVEL = dict(
+    [(2, "0, Neolithic-level technology"),
+     (3, "1, Medieval technology")] +
+    [(n, "2, Early Industrial Age technology") for n in range(4, 6)] +
+    [(n, "4, Modern postech") for n in range(6, 9)] +
+    [(n, "3, Early 21st century equivalent technology") for n in range(9, 11)] +
+    [(11, "4+, Postech with specialities"),
+     (12, "5, Pretech with surviving infrastructure")])
+
+
+class PlanetManagerPrompt(cmd.Cmd):
+    intro = "Welcome to the planet manager"
+    prompt = '> '
+
+    def __init__(self, sql_link, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.link = sql_link
+        self.converters = Converters(sql_link)
+        self.do_connect_database()
+        self.initialize()
+
+    def do_connect_database(self, inp=None):
+        '''Connect to the database'''
+        self.link.connect()
+
+    def initialize(self):
+        print("Initializing tables")
+        self.link.execute_query(self.converters.planet.create_table_query)
+        self.link.execute_query(self.converters.connection.create_table_query)
+
+    def do_reset(self, inp):
+        '''Reset the database back to it's factory settings'''
+        confirm = input('Are you sure? This will wipe the database. Y/N: ')
+        if (confirm.lower().strip() == 'y'):
+            print("Wiping existing tables")
+            self.link.execute_query(f"DROP TABLE {self.converters.planet.table_name}", suppress=True)
+            self.link.execute_query(f"DROP TABLE {self.converters.connection.table_name}", suppress=True)
+            print("Creating new tables")
+            self.link.execute_query(self.converters.planet.create_table_query)
+            self.link.execute_query(
+                self.converters.connection.create_table_query)
+        else:
+            print("Reset aborted")
+
+    def do_exit(self, inp):
+        '''Exit the tool'''
+        print("Closing database connection")
+        self.link.close()
+        print("Quitting planet manager")
+        return True
+
+    def do_add(self, inp):
+        '''
+        usage: add name coordinates [tl=0] [bio=1] [atmos=2] [temp=3] [pop=4]
+
+        Adds a new planet to the database at the specified coordinate and name
+        '''
+        try:
+            args = inp.split(' ')
+            name = args[0]
+            coords = Coordinate.from_hex(args[1])
+            optionals = self.parse(args[2:])
+            self.converters.planet.add(name=name, coords=coords, **optionals)
+            self.link.commit()
+        except Exception as e:
+            print("Error padding new planet: ", e)
+
+    def do_update(self, inp):
+        '''
+        usage: update name [tl=0] [bio=1] [atmos=2] [temp=3] [pop=4]
+
+        updates the named planets fields with new values
+        '''
+        try:
+            args = inp.split(' ')
+            name = args[0]
+            optionals = self.parse(args[1:])
+            self.converters.planet.update(name=name, **optionals)
+            self.link.commit()
+        except Exception as e:
+            print("Error updating planet: ", e)
+
+    def do_description(self, inp):
+        ''' Set the description for the planet'''
+        args = inp.split(' ')
+        name = args[0]
+        description = ' '.join(args[1:])
+        self.converters.planet.update(name=name, desc=description)
+        self.link.commit()
+
+    def do_connect(self, inp):
+        '''
+        usage: connect hex1 hex2
+
+        creates a bidirectional connection between two listed hexes
+        '''
+        try:
+            args = inp.split(' ')
+            hex1 = args[0].upper()
+            hex2 = args[1].upper()
+            self.converters.connection.add(hex1, hex2)
+            self.converters.connection.add(hex2, hex1)
+            self.link.commit()
+        except Exception as e:
+            print("Error connecting hexes: ", e)
+
+    def do_info(self, inp):
+        '''Gets information for a planet'''
+        planet = self.converters.planet.load_by_name(inp)
+        divider = "-" * 10
+        print(divider)
+        print(f"{planet.coordinates}" + " - " + planet.name)
+        print(divider)
+        print("Atmosphere: " + ATMOSPHERES.get(planet.atmosphere, "UNKNOWN"))
+        print("Temperature: " + TEMPERATURES.get(planet.temperature, "UNKNOWN"))
+        print("Biosphere: " + BIOSPHERES.get(planet.biosphere, "UNKNOWN"))
+        print("Population: " + POPULATIONS.get(planet.population, "UNKNOWN"))
+        print("Tech Level: " + TECHLEVEL.get(planet.tl, "UNKNOWN"))
+        print(divider)
+        print(planet.description.replace('\\n', '\n')
+              if planet.description is not None else "")
+        print(divider)
+        self.connected(planet)
+        print(divider)
+
+    def connected(self, planet):
+        if planet:
+            linked = link.execute_read_query(f"""
+                SELECT name, coordinate FROM planets
+                WHERE coordinate IN (
+                    SELECT
+                        end_hex
+                    FROM
+                        planetary_connections
+                    WHERE
+                        start_hex='{planet.coordinates}'
+                )
+                """)
+            if linked:
+                print(f"{planet.name} connects to:")
+                print(f"{'Name':<16}Distance")
+            for name, coord in linked:
+                coordinate = Coordinate.from_hex(coord)
+                steps_to_column = abs(coordinate.x - planet.coordinates.x)
+                steps_to_row = abs(coordinate.y - planet.coordinates.y)
+                if (coordinate.y - planet.coordinates.y < 0) ^ (coordinate.x % 2 == 0):
+                    saved_steps_from_columns = math.floor(
+                        (steps_to_column + 1) / 2)
+                else:
+                    saved_steps_from_columns = math.floor(steps_to_column / 2)
+
+                steps_to_row = max(0, steps_to_row - saved_steps_from_columns)
+
+                distance = steps_to_column + steps_to_row
+                print(f"{name:<16}{distance}")
+
+    def do_delete(self, name):
+        '''
+        usage: delete planet_name
+        deletes the planet from the database
+        '''
+        if self.link.planet.check_exists(name):
+            planet = self.link.planet.load_by_name(name)
+            confirm = input(f'{name} found at hex {planet.coordinates}. Are you sure you wish to delete? Y/N ')
+            if (confirm.lower().strip() == 'y'):
+                self.link.planet.delete(planet)
+        else:
+            print(f"No planet found for provided name: {name}")
+
+    def do_list(self, inp):
+        '''
+        usage: list [planets/conns]
+        Prints the available planets, connections
+        '''
+        if (inp == 'planets'):
+            [print(f"{f'{p.coordinates}':<3} - " + p.name) for p in self.converters.planet.load_all('name')]
+        elif (inp == 'conns'):
+            [print(f"{c.start_hex} -> {c.end_hex}") for c in self.converters.connection.load_all()]
+
+    def do_EOF(self, inp):
+        '''Exit the tool'''
+        return self.do_exit(inp)
+
+    def do_query(self, arg):
+        ''' Executes the provided SQL query on the database. '''
+        print(self.link.execute_read_query(arg))
+
+    def parse(self, arg_list):
+        args = dict()
+        for pair in arg_list:
+            arg, val = pair.split("=")
+            args[arg] = val
+
+        return args
+
+
+if __name__ == "__main__":
+    link = SQLDatabaseLink("./system.db")
+    manager_prompt = PlanetManagerPrompt(link)
+    manager_prompt.cmdloop()
+    link.close()

Create CLI tool for managing planets
As a user i expect to be able to easily manipulate the existing planets in my database

This issue covers the work to create a simple command line tool that can:

- display what planets are  in the database
- Add additional planets to the database
- Display information about a specific planet (either by id or by name (autocomplete-able))
- Add additional information to the description of a planet
diff --git a/test/converters/test_planetconverter.py b/test/converters/test_planetconverter.py
index 7dd6c88..73464c9 100644
--- a/test/converters/test_planetconverter.py
+++ b/test/converters/test_planetconverter.py
@@ -139,7 +139,7 @@ def test_delete_deletes(converter: PlanetConverter, sql_connection):
 
 
 def test_load_by_id_loads(converter: PlanetConverter, sql_connection, planet, monkeypatch):
-    sql_connection.set_execute_read_results([planet])
+    sql_connection.set_execute_read_results([[planet,]])
     with monkeypatch.context() as m:
         m.setattr(Planet, "deserialize", lambda n: n)
         returned = converter.load_by_id(planet.ID)
@@ -179,12 +179,12 @@ def test_check_exists_returnsFalseIfNoIdFound(converter: PlanetConverter, sql_co
 
 
 def test_load_by_name_loads(converter: PlanetConverter, sql_connection, planet, monkeypatch):
-    sql_connection.set_execute_read_results([planet])
+    sql_connection.set_execute_read_results([[planet,]])
     with monkeypatch.context() as m:
         m.setattr(Planet, "deserialize", lambda n: n)
         returned = converter.load_by_name(planet.name)
 
-    expected_query = f"SELECT * FROM planets WHERE name = {planet.name}"
+    expected_query = f"SELECT * FROM planets WHERE name = '{planet.name}'"
     queries = sql_connection.get_queries()
     load_query = queries[0]
     assert returned == planet
@@ -192,7 +192,7 @@ def test_load_by_name_loads(converter: PlanetConverter, sql_connection, planet,
 
 
 def test_load_all_loads(converter: PlanetConverter, sql_connection, planet, monkeypatch):
-    sql_connection.set_execute_read_results([[planet]])
+    sql_connection.set_execute_read_results([[planet,]])
     with monkeypatch.context() as m:
         m.setattr(Planet, "deserialize", lambda n: n)
         returned = converter.load_all()
@@ -202,3 +202,15 @@ def test_load_all_loads(converter: PlanetConverter, sql_connection, planet, monk
     load_query = queries[0]
     assert returned == [planet]
     assert load_query == expected_query
+
+def test_load_all_loadsAndSorts(converter: PlanetConverter, sql_connection, planet, monkeypatch):
+    sql_connection.set_execute_read_results([[planet,]])
+    with monkeypatch.context() as m:
+        m.setattr(Planet, "deserialize", lambda n: n)
+        returned = converter.load_all(order="name")
+
+    expected_query = f"SELECT * FROM planets ORDER BY name ASC"
+    queries = sql_connection.get_queries()
+    load_query = queries[0]
+    assert returned == [planet]
+    assert load_query == expected_query
\ No newline at end of file

pip install -e .[dev]
parse_log_pytest
pytest
pytest pytest-cov
apt-get update apt-get install -y gcc
3.7
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///croot/attrs_1668696182826/work
certifi @ file:///croot/certifi_1671487769961/work/certifi
coverage==7.2.7
flit_core @ file:///opt/conda/conda-bld/flit-core_1644941570762/work/source/flit_core
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1648562407465/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
packaging @ file:///croot/packaging_1671697413597/work
pluggy @ file:///tmp/build/80754af9/pluggy_1648042572264/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pytest==7.1.2
pytest-cov==4.1.0
-e git+https://github.com/Robtom5/swn_database.git@b735d495cae975ff6c9fce8542a84d21fd1c0de7#egg=swn_database
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
typing_extensions @ file:///croot/typing_extensions_1669924550328/work
zipp @ file:///croot/zipp_1672387121353/work

name: swn_database
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=22.1.0=py37h06a4308_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - flit-core=3.6.0=pyhd3eb1b0_0
  - importlib-metadata=4.11.3=py37h06a4308_0
  - importlib_metadata=4.11.3=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=22.0=py37h06a4308_0
  - pip=22.3.1=py37h06a4308_0
  - pluggy=1.0.0=py37h06a4308_1
  - py=1.11.0=pyhd3eb1b0_0
  - pytest=7.1.2=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py37h06a4308_0
  - typing_extensions=4.4.0=py37h06a4308_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.11.0=py37h06a4308_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.2.7
      - pytest-cov==4.1.0
prefix: /opt/conda/envs/swn_database

test/converters/test_planetconverter.py::test_load_by_id_loads test/converters/test_planetconverter.py::test_load_by_name_loads test/converters/test_planetconverter.py::test_load_all_loadsAndSorts
test/converters/test_planetconverter.py::test_create_table_query_ReturnsExpectedQuery test/converters/test_planetconverter.py::test_add_addsCorrectEntry[add_args0-('mockplanet', test/converters/test_planetconverter.py::test_add_addsCorrectEntry[add_args1-('mockplanet', test/converters/test_planetconverter.py::test_add_addsCorrectEntry[add_args2-('mockplanet', test/converters/test_planetconverter.py::test_add_addsCorrectEntry[add_args3-('mockplanet', test/converters/test_planetconverter.py::test_add_returnsNoneIfFailsToAdd test/converters/test_planetconverter.py::test_update_planet_executesCorrectQuery test/converters/test_planetconverter.py::test_update_returnsNoneIfNoValuesToUpdate test/converters/test_planetconverter.py::test_update_returnsNoneIfFailsToUpdate test/converters/test_planetconverter.py::test_delete_deletes test/converters/test_planetconverter.py::test_check_exists_returnsTrueIfIdIsFound test/converters/test_planetconverter.py::test_check_exists_returnsFalseIfNoIdFound test/converters/test_planetconverter.py::test_load_all_loads
swn_database/converters/ConnectionConverter.py tools/planet_manager.py swn_database/SQLDatabaseLink.py swn_database/converters/PlanetConverter.py
swn_database/converters/ConnectionConverter.py tools/planet_manager.py swn_database/SQLDatabaseLink.py swn_database/converters/PlanetConverter.py