aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorzlg <zlg@zlg.space>2018-09-03 02:52:57 -0700
committerzlg <zlg@zlg.space>2018-09-03 02:55:21 -0700
commit565812a92cd22d41aa6f5f85a6b451386422fb4a (patch)
tree5858923eb443465c078040c0e8cc13739290f195
parentFlesh out filter types and ownership status (diff)
downloadvgstash-565812a92cd22d41aa6f5f85a6b451386422fb4a.tar.gz
vgstash-565812a92cd22d41aa6f5f85a6b451386422fb4a.tar.bz2
vgstash-565812a92cd22d41aa6f5f85a6b451386422fb4a.tar.xz
vgstash-565812a92cd22d41aa6f5f85a6b451386422fb4a.zip
Branch off from master with pytest, tox, click
This commit is huge, but contains everything needed for a "proper" build system built on pytest + tox and a CLI built with click. For now, this branch will contain all new vgstash development activity until it reaches feature parity with master. The CLI is installed to pip's PATH. Only the 'init', 'add', and 'list' commands work, with only two filters. This is pre-alpha software, and is therefore not stable yet.
-rw-r--r--.gitignore14
-rw-r--r--AUTHORS2
-rw-r--r--LICENSE (renamed from COPYING)0
-rw-r--r--MANIFEST.in3
-rw-r--r--README.md115
-rw-r--r--README.mdown279
-rw-r--r--TODO.txt7
-rw-r--r--requirements.txt2
-rwxr-xr-xscripts/dupe-finder.sh15
-rwxr-xr-xsetup.py46
-rwxr-xr-xsrc/vgstash/__init__.py297
-rw-r--r--src/vgstash/test_vgstash.py145
-rw-r--r--src/vgstash/test_vgstash_cli.py73
-rw-r--r--src/vgstash_cli.py64
-rw-r--r--tox.ini13
-rwxr-xr-xvgstash482
16 files changed, 779 insertions, 778 deletions
diff --git a/.gitignore b/.gitignore
index 0fd1bcf..4775e15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,13 @@
-/*.sw?
+*.sw?
+
+# pytest and friends
+*.dist-info/
+*.egg-info/
+__pycache__/
+.pytest_cache/
+.ropeproject/
+.tox/
+/build/
+/dist/
+
+.vgstash.db
diff --git a/AUTHORS b/AUTHORS
index 16f9dc6..bc63e0e 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,2 +1,2 @@
-zlg, gopher://zelibertinegamer.me
+zlg, gopher://zlg.space <zlg@zlg.space>
Original author, maintainer
diff --git a/COPYING b/LICENSE
index 94a9ed0..94a9ed0 100644
--- a/COPYING
+++ b/LICENSE
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..b44eea6
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include AUTHORS
+include LICENSE
+include README.md
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5d42320
--- /dev/null
+++ b/README.md
@@ -0,0 +1,115 @@
+# vgstash - a place to stash your game collection
+
+If you love video games, you've probably amassed a collection of them, across
+many different systems; physical, digital, and everything in-between. At some
+point, a player may want to know a few key pieces of information that may steer
+their gaming. These questions are great for quelling boredom and keeping a
+gaming backlog manageable:
+
+* Which games do I own?
+* Which games have I beaten or completed?
+* Which games do I need to beat?
+* What was the note I left for X game?
+
+vgstash seeks to answer these type of questions in a simple and extensible way.
+
+# Installation
+
+vgstash is available via `pip`:
+
+~~~
+pip install [--user] vgstash
+~~~
+
+Packages for vgstash on Gentoo Linux and Adélie Linux are on the TODO list.
+
+Please note that vgstash is under heavy development at present. Do not install
+unless you are interested in helping its development.
+
+# Concept
+
+The core concept of vgstash is the game itself. Every game in a player's
+collection has a few important attributes, all of which are obvious to the
+player:
+
+* Title
+* System
+* Ownership
+ * unowned
+ * physical
+ * digital
+ * both
+* Progress
+ * new
+ * playing
+ * beaten
+ * complete
+* Notes
+
+Think of any game that you have a history with. Let's say it was a game you
+bought as part of a Humble Bundle, but haven't started playing yet. Internally,
+vgstash tracks it somewhat like this:
+
+```
+.--------------------------------------------------------.
+| Title | System | Ownership | Progress |
+|------------------------+--------+-----------+----------|
+| FTL: Faster Than Light | Steam | digital | new |
+'--------------------------------------------------------'
+```
+
+This is the bare minimum information you need to meaningfully track a video
+game in your collection.
+
+vgstash comes with a command line client of the same name, which gives you
+high level commands to manipulate the database with. It's the reference
+implementation for a vgstash client.
+
+If you wanted to add the above game to your collection, you'd do it like this:
+
+```bash
+$ vgstash add 'FTL: Faster Than Light' Steam d n
+Added FTL: Faster Than Light for Steam. You digitally own it and you have not started it.
+```
+
+Pretty easy, huh? Each game and system added to vgstash is free-form and can
+be tuned to match the user's preferences. vgstash has a fairly small set of
+commands:
+
+* add
+* delete
+* update
+* list
+
+The power is in the `list` command. vgstash comes with a set of default filters
+that allow you to reason about your game collection. For example, this command
+will show you every game marked "playing" that you also own in some way:
+
+```bash
+$ vgstash list playlog
+# listed output here
+```
+
+Consult `vgstash -h` for further usage information.
+
+# Roadmap
+
+These are planned for the full 0.3 release:
+
+* command line interface finished
+* Match feature-set with `master`
+
+Goals planned for the 0.4 release:
+
+* import and export with JSON
+
+Goals planned for the 0.5 release:
+
+* some sort of GUI (Tk and Qt are current candidates)
+
+Goals planned for the 1.0 release:
+
+* Kivy-based interface (to release on Android via F-Droid)
+
+If this interests you, please [e-mail me](mailto:zlg+vgstash@zlg.space) or find
+me on the Fediverse: [@zlg@social.zlg.space](https://social.zlg.space/users/zlg)
diff --git a/README.mdown b/README.mdown
deleted file mode 100644
index ace50e6..0000000
--- a/README.mdown
+++ /dev/null
@@ -1,279 +0,0 @@
-vgstash is a tool used to keep track of a video game collection. In addition to
-basic inventory maintenance, vgstash supports the ability to clarify whether or
-not the user owns a game, and their progress through said game. This makes it
-easy to manage a backlog and serve as a "memory" for which games someone has
-finished.
-
-# Concept
-
-Essentially, vgstash just gets out of your way and lets you manage things.
-Nothing fancy like box art or finding games in a massive dropdown box. There are
-easy flags to track common searches, like "All games that are owned and haven't
-been beaten", making vgstash convenient.
-
-Since vgstash itself is just a front-end to an SQLite database, other interfaces
-to this database format can be built to extend the basic concept. SQLite is
-supported across tons of languages and platforms, so it's rather trivial to
-build another frontend to it. If that's not enough, vgstash also supports
-exporting collections as YAML or pipe-delimited lines, and importing from YAML.
-This makes batch-editing by a human easy with YAML, and the raw format is great
-for pushing vgstash's output through pipes, as any good
-\*nix tool should.
-
-## Data Format
-
-The key to managing things is keeping it simple, but flexible enough to give you
-meaningful insight to the data itself. vgstash gets straight to business
-with its data format:
-
-* Name (string)
-* System (string)
-* Ownership (boolean)
-* Completion Level (integer)
- * 0 (Fresh)
- * 1 (In Progress)
- * 2 (Beaten)
- * 3 (Completed)
-
-There isn't a hard limit on the length of the Name or System strings. SQLite
-itself, however, has internal limits. Those limits may differ between platforms,
-so there's no guarantee that vgstash databases will work across them. The
-`import` and `export` functions should be used to transport vgstash databases
-from one platform to another, as YAML is better-standardized and easily
-modifiable.
-
-# Usage
-
-Using vgstash is fairly easy, for a command-line program. It accepts
-a handful of commands, which will be explained below:
-
-* init
-* add
-* update
-* delete
-* list
-* import
-* export
-
-## Tips
-
-For the sake of accessibility and ease of use, there are some synonyms:
-
-When specifying ownership, `0` (zero) and `n` mean the same thing. The same is
-true of `1` (one) and `y`.
-
-When specifying progress, the numbers and letters can both be used:
-
-`0` is also `f` or "fresh".
-`1` is also `i` (lowercase I) or "in-progress".
-`2` is also `b` (lowercase B) or "beaten".
-`3` is also `c` (lowercase C) or "completed".
-
-Since vgstash relies on the shell and SQLite to function, unquoted strings
-are likely to not be handled well. If your data ever looks funky, try correcting
-it with a quoted string. Bugs or issues that are reported without quoted strings
-will be discarded as WONTFIX, as that's basic, sane practice in every shell
-under the sun.
-
-## Getting Started
-
-Initialize the database, where your information will be stored:
-
-~~~
-vgstash init
-~~~
-
-The location of the database may be set using an environment variable. To learn
-more, see the Environment Variables section.
-
-## Adding
-
-Next, add a game or two:
-
-~~~
-vgstash add "Super Mario Bros" NES y i
-~~~
-
-In the above example, we're adding *Super Mario Bros* for the NES system. We own
-it, and haven't beaten it yet.
-
-Let's add another:
-
-~~~
-vgstash add "Borderlands 2" PC
-~~~
-
-This command has just the basics: game name, and game platform. For the sake
-of simplicity, the ownership and progress flags have default values: ownership
-defaults to "y" for "yes" and progress defaults to "i" for "in-progress". In
-both cases, they are the most typical state for a video game to be added to ones
-collection. This can be influenced; see the Environment Variables section for
-more info.
-
-## Listing
-
-vgstash has plenty of methods to interface with your game collection. The
-simplest listing would be "list every game in the database":
-
-~~~
-# The 'all' is optional
-vgstash list all
-~~~
-
-This will output a human-readable list of games you have in the database, with
-proper flags and unique IDs, which will come in handy later. For ease of use,
-`list all` will sort by system, then by title. Here's an example:
-
-~~~
-ID | Title | System | Own | Progress
----+---------------------+--------+-----+----------
-3 | Metroid | NES | * | I
-1 | Super Mario Bros | NES | | C
-2 | The Legend of Zelda | NES | * | B
-4 | Borderlands | PC | * | F
-~~~
-
-From the above output, we can gather that we don't own *Super Mario Bros*
-anymore. It's been completed, however, so that's good news. We own *Zelda*, and
-it's been beaten, but not completed. We also own *Metroid*, but it's still
-in-progress. Lastly, we own Borderlands for the PC, but haven't started playing
-it yet.
-
-The output format is designed to be easy to read, and omits information to serve
-as a visual guide whenever possible. From a single glance, you should be able to
-spot which games you own and which haven't been started yet.
-
-### List Filtering
-
-Seeing all of your games is great when you're just getting started, but what
-about after you've added your massive 500 game collection? Nobody wants to sort
-through screenfulls of text. That's where filtering comes in. The `list` command
-accepts the following filters:
-
-* **arcade**
- Games you own that cannot be beaten.
-
-* **backlog**
- Games you own that have not been beaten or completed.
-
-* **borrowing**
- Games you don't own and are playing.
-
-* **complete**
- Games that have been completed.
-
-* **digital**
- Games you own digitally.
-
-* **done**
- Games that have been beaten *or* completed.
-
-* **incomplete**
- Games you own that have been beaten, but not completed.
-
-* **new**
- Games you own and haven't played yet.
-
-* **owned**
- Games you own.
-
-* **physical**
- Games you own physically.
-
-* **playlog**
- Games you own that are marked 'playing'.
-
-* **unowned**
- Games you don't own.
-
-* **wishlist**
- Games you don't own *and* haven't played yet.
-
-## Update
-
-The `update` command requires the game's ID, the field you're changing, and the
-value you're changing it to.
-
-The fields you can change are
-
-* title
-* system
-* ownership
-* progress
-
-As explained in **Data Format**, ownership and progress can use single letters
-to substitute for the internal numeric representations.
-
-To get a game's ID, try using something like `vgstash list | grep "foo"` to find
-what you're looking for.
-
-## Deleting
-
-For one reason or another, you may need to remove items from your game database.
-The `delete` command, coupled with the game's ID, will take care of that
-for you. If you want to remove *every* game from your collection, delete
-`$HOME/.vgstash.db` (or wherever `VGSTASH_DB_LOCATION` is set to) and start over
-with `vgstash init`.
-
-# Environment Variables
-
-Customization is pretty important if you're going to manage hundreds of games.
-There are only a few, but they may come in handy for you!
-
-* `VGSTASH_DB_LOCATION`
- Defaults to `$HOME/.vgstash.db`.
-
-* `VGSTASH_DEFAULT_OWNERSHIP`
- Can be 0 or 1. Default is 1 (yes).
-
-* `VGSTASH_DEFAULT_PROGRESS`
- Can be 0 through 3. Default is 1 (in-progress).
-
-* `VGSTASH_TABLE_WIDTH`
- The width of the table output by the `list` command. For readability
-reasons, vgstash will only allow values that are 50 or above. If this variable
-is set to zero (0) however, it will expand to fit the entire width of your
-terminal/tty. It defaults to 80 characters.
-
-# Contributing
-
-vgstash was first written in Python 3.4. There are no plans to produce a Python
-2.x version. Patches and pull requests are welcome! However, please do not
-contribute to this project unless you are fine with your contributions also
-being licensed under the GPL 3.0. This is to simplify licensing issues and keep
-things neat.
-
-Before submitting a Pull Request, please ensure that your commit(s) contain an
-addition of your name to the `AUTHORS` file. Others might not be using git to
-get or use vgstash, and every contributor deserves recognition. An example of
-what one would add to the `AUTHORS` file is:
-
-~~~
-John Q. Public, GitHub: @hellomynameisjohn <jqp@genericville.com>
- Corrected typos in manpage
-~~~
-
-Note that the indent is four spaces.
-
-# Roadmap
-
-vgstash is considered beta-quality at this time. Please report any issues, bugs,
-or even suggestions on how to improve vgstash.
-
-One feature that will not be built (at least by zlg) is search functionality.
-Since vgstash cooperates with piping, it's trivial to pass its output through
-other programs and get the fine-grained information a more advanced user may
-need.
-
-Here are the current goals:
-
-* manpage
-* bash-completion file
-* refined argument handling for shorthand commands
-
-# Copyright
-
-vgstash is Copyright © 2016-2018 zlg. It is licensed under the GNU General
-Public License, version 3.0. A copy of this license may be found in the
-`COPYING` file within this project. It may also be found on the World Wide Web
-at http://gnu.org/licenses/gpl-3.0.html.
diff --git a/TODO.txt b/TODO.txt
new file mode 100644
index 0000000..9e851ef
--- /dev/null
+++ b/TODO.txt
@@ -0,0 +1,7 @@
+* Get tests running in their own directory
+* Write CLI
+ * in progress, using click - https://click.pocoo.org/
+* Write GUI
+* Write docs
+ * How? Sphinx? Needs research
+* Package for Gentoo and Adélie
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..7df6387
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+Click
+pytest
diff --git a/scripts/dupe-finder.sh b/scripts/dupe-finder.sh
deleted file mode 100755
index 93d84c9..0000000
--- a/scripts/dupe-finder.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/env bash
-
-# dupe-finder.sh: Output duplicate games in your database
-
-# Set options for improved robustness
-set -o errexit
-set -o pipefail
-set -o nounset
-
-# We're setting the envvar here to enforce the -w argument to uniq
-result=$(VGSTASH_TABLE_WIDTH=80 vgstash list | uniq -D -f 2 -w 58)
-if [[ -n $result ]]; then
- VGSTASH_TABLE_WIDTH=80 vgstash list | head -n 2
- echo "$result"
-fi
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..82fd062
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+
+# Learn more: https://github.com/kennethreitz/setup.py
+
+from setuptools import setup, find_packages
+
+
+with open('README.md') as f:
+ readme = f.read()
+
+setup(
+ name='vgstash',
+ version='0.3-alpha3',
+ description='a video game collection management module, backed by SQLite',
+ long_description=readme,
+ long_description_content_type="text/markdown; variant=CommonMark",
+ author='zlg',
+ author_email='zlg+vgstash@zlg.space',
+ url='https://git.zlg.space/vgstash',
+ packages=find_packages(where='src'),
+ package_dir={'': 'src'},
+ py_modules=['vgstash_cli'],
+ entry_points={
+ 'console_scripts': [
+ 'vgstash=vgstash_cli:cli',
+ 'vgstash_tk=vgstash_tk:main'
+ ],
+ },
+ install_requires=[
+ 'Click>=6.0', # for better Windows console support
+ ],
+ #py_modules=["vgstash"],
+ classifiers=(
+ "Development Status :: 2 - Pre-Alpha",
+ "License :: OSI Approved :: GNU Affero General Public License v3",
+ "Operating System :: POSIX :: Linux",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Games/Entertainment",
+ "Topic :: Utilities",
+ ),
+ project_urls={
+ 'Bug Reports via Web': 'https://code.foxkit.us/zlg/vgstash/issues',
+ 'Source': 'https://git.zlg.space/vgstash',
+ }
+)
+
diff --git a/src/vgstash/__init__.py b/src/vgstash/__init__.py
new file mode 100755
index 0000000..61ae9e6
--- /dev/null
+++ b/src/vgstash/__init__.py
@@ -0,0 +1,297 @@
+import os
+import sys
+import sqlite3
+
+# Remixed vgstash. This time I chose an individual file on purpose:
+#
+# * Only need a single module to interface with a game DB. That's awesome.
+# * Keeps the backend separate from the UI, as intended.
+# * SQLA and pandas are both too large and unwieldy for my intent, and deps
+# will need to be kept low if I want to be cross-platform. sqlite3's part of
+# the standard library, can't beat that.
+# * GUI will be implemented in tkinter, Qt, and/or Kivy.
+
+PROGRESS = {
+ 'unbeatable': 0,
+ 'new': 1,
+ 'playing': 2,
+ 'beaten': 3,
+ 'complete': 4,
+}
+
+OWNERSHIP = {
+ 'unowned': 0,
+ 'physical': 1,
+ 'digital': 2,
+ 'both': 3
+}
+
+DEFAULT_CONFIG = {
+ 'db_location': os.getenv('VGSTASH_DB_LOCATION', os.path.join(os.getenv('HOME', os.curdir), '.vgstash.db')),
+ 'progress': os.getenv('VGSTASH_DEFAULT_PROGRESS', PROGRESS['playing']),
+ 'ownership': os.getenv('VGSTASH_DEFAULT_OWNERSHIP', OWNERSHIP['physical'])
+}
+
+FILTERS = {
+ 'allgames': "SELECT * FROM games ORDER BY system, title",
+ 'backlog': "SELECT * FROM games WHERE ownership > 0 AND progress = 0 OR progress = 1 ORDER BY system, title",
+ 'playlog': "SELECT * FROM games WHERE ownership > 0 AND progress = 2 ORDER BY system, title"
+}
+
+def kvmatch(arg, dict_map, fallback):
+ """
+ Match arg against keys or values in dict_map, returning fallback if no match.
+
+ This function performs a prefix-match against the keys in dict_map. Doing
+ such iteration partially defeats the purpose of using a dictionary, but
+ it offers considerable ease-of-use for the CLI and acts as a validation
+ function that will always return a valid value ready for committing to the
+ database.
+
+ It is generalized to support any value that needs to be mapped to an
+ integer, via the dict_map.
+ """
+ try:
+ ret = int(arg)
+ except TypeError:
+ ret = fallback
+ except ValueError:
+ found = False
+ for k in dict_map.keys():
+ if k.startswith(arg):
+ ret = dict_map[k]
+ found = True
+ break
+ if not found:
+ ret = fallback
+ finally:
+ if ret not in dict_map.values():
+ ret = fallback
+ return ret
+
+
+class DB(object):
+ """
+ The central class of vgstash. It handles everything relating to storing the
+ game collection.
+ """
+ def __init__(self, path=DEFAULT_CONFIG['db_location']):
+ """
+ Initiates the DB object with a 'conn' attribute, which holds the SQLite
+ connection. Additionally, the connection's 'row_factory' attribute is
+ set to sqlite3.Row to allow for string *and* integer indexes. This value
+ may be changed by the caller after the object is returned.
+ """
+ try:
+ self.conn = sqlite3.connect(path)
+ self.conn.row_factory = sqlite3.Row
+ except sqlite3.OperationalError as e:
+ print("%s: %s".format(e, path))
+ exit()
+
+ def add_filter(self, filter_name, stmt):
+ """
+ Adds a new filter keyword to the database. Note that values are passed
+ directly in, with no escaping. Use with caution.
+
+ 'filter_name' is the name of your filter, and will be the 'name' key
+ when viewed with list_filters(). usable via `vgscli list [filter name]`.
+ 'stmt' is a plain SELECT statement representing the SQLite VIEW.
+ """
+ if filter_name.startswith("sqlite_"):
+ raise ValueError("Cannot create a filter with the 'sqlite_' prefix.")
+
+ # The call to format() is needed because sqlite doesn't allow
+ # parameterized view or table names. This may affect database
+ # integrity, and may disappear in a later release.
+ res = self.conn.execute("CREATE VIEW\
+ IF NOT EXISTS \
+ '{}'\
+ AS\
+ {}".format(filter_name, stmt))
+ FILTERS[str(filter_name)] = str(stmt)
+ return self.has_filter(filter_name)
+
+ def add_game(self, game, update=True):
+ """
+ Adds a Game to the database. Returns True on success and False on
+ failure.
+ """
+ if self.has_game(game) and update:
+ return self.update_game(game, game)
+ else:
+ c = self.conn.execute("INSERT INTO games\
+ (title, system, ownership, progress, notes)\
+ VALUES\
+ (?, ?, ?, ?, ?)",
+ (game.title, game.system, game.ownership, game.progress, game.notes))
+ self.conn.commit()
+ return (c.rowcount > 0)
+
+ def create_schema(self):
+ """
+ Initializes the database with this basic schema:
+
+ games:
+ title (TEXT)
+ system (TEXT)
+ ownership (INTEGER)
+ progress (INTEGER)
+ notes (TEXT)
+
+ The schema is configured to use the 'title' and 'system' columns as
+ primary keys, meaning it is impossible for two games with the same name
+ and system to be present in the database.
+
+ Additionally, create_schema will add the default set of filters, which
+ are considered important for an ordinary vgstash DB.
+ """
+ # The UNIQUE clause ensures that no two games with the same title and
+ # system are added to the database. An sqlite3.IntegrityError will be
+ # raised when a match occurs; clients can decide how to handle it.
+ #
+ # The 'rowid' field is automatically generated by sqlite, and is thus
+ # omitted. External modifications to the database should be done
+ # through additional tables, and should reference the 'rowid' field to
+ # get more exact data manipulation. Alternatively, use the *_filter
+ # methods of this class to create custom reporting filters.
+ try:
+ self.conn.execute("CREATE TABLE\
+ IF NOT EXISTS\
+ games (\
+ title TEXT NOT NULL,\
+ system TEXT NOT NULL,\
+ ownership INTEGER NOT NULL DEFAULT 1,\
+ progress INTEGER NOT NULL DEFAULT 1,\
+ notes TEXT DEFAULT '',\
+ UNIQUE (title, system) ON CONFLICT ROLLBACK\
+ )")
+ # setup default filters while we're here
+ for name, sql in sorted(FILTERS.items()):
+ self.add_filter(name, sql)
+ return True
+ except sqlite3.OperationalError:
+ print("Table already exists, skipping.")
+ return False
+
+ def delete_filter(self, filter_name):
+ with self.conn:
+ # try:
+ self.conn.execute("DROP VIEW IF EXISTS '{}'".format(filter_name))
+ return True
+ # except:
+ # sys.exc_info()
+ # print("Could not remove filter '{}'.".format((filter_name))
+ return False
+
+ def delete_game(self, game):
+ """
+ Deletes a game from the database. Returns True on success and False
+ on failure.
+ """
+ if self.has_game(game):
+ c = self.conn.cursor()
+ c.execute("DELETE FROM games\
+ WHERE title=? AND system=?",
+ (game.title, game.system))
+ self.conn.commit()
+ return True
+ else:
+ return False
+
+ def has_game(self, game, fuzzy=False):
+ """
+ Returns whether or not the game is in the database.
+
+ game - The Game object to search for.
+ fuzzy - Fuzzy search, using internal 'LIKE' and replacing the game
+ title's spaces with '%' characters. Defaults to False.
+ """
+ if fuzzy:
+ game.title = "%".join(['', game.title.replace(" ", "%"), ''])
+ stmt = "SELECT * FROM games WHERE title LIKE ? AND system=?"
+ else:
+ stmt = "SELECT * FROM games WHERE title=? AND system=?"
+
+ res = self.conn.execute(stmt, (game.title, game.system)).fetchone()
+ # res is None if there isn't a match, which evaluates to False
+ return bool(res)
+
+ def has_filter(self, filter_name):
+ return filter_name in self.list_filters().keys() \
+ and filter_name in FILTERS
+
+ def list_filters(self):
+ """
+ Provides an iterable of filter names and their associated SELECT
+ statements.
+ """
+ # The 'sqlite_master' table is a built-in.
+ # This returns an iterable of sqlite3.Row, which can be accessed
+ # similarly to a dictionary.
+ res = self.conn.execute(\
+ "SELECT name,sql\
+ FROM sqlite_master\
+ WHERE\
+ type='view'\
+ ORDER BY name ASC").fetchall()
+ ret = {}
+ for row in res:
+ ret[row['name']] = row['sql']
+ # Be sure to sync with internal representation
+ FILTERS = ret
+ return ret
+
+ def list_games(self, filter='allgames'):
+ if filter not in FILTERS.keys():
+ filter = 'allgames'
+ return self.conn.execute(FILTERS[filter]).fetchall()
+
+ def update_filter(self, filter_name, stmt):
+ """
+ Updates a filter's definition within the database.
+
+ SQLite does not have a way to update VIEWs directly, so this is a
+ convenience function to make updating possible.
+ """
+ try:
+ self.delete_filter(filter_name)
+ self.add_filter(filter_name, stmt)
+ return True
+ except:
+ return False
+
+ def update_game(self, target, source):
+ """
+ Look for target in the database and (if found) update it with source's
+ information. Returns True on success and False on failure.
+
+ The same Game object may be passed as both target and source to update
+ a given game's values within the database.
+ """
+ # don't update unless it exists
+ if self.has_game(target):
+ c = self.conn.cursor()
+ # TODO: do this better
+ c.execute("UPDATE games\
+ SET title=?, system=?, ownership=?, progress=?\
+ WHERE title=? AND system=?",
+ (source.title, source.system, source.ownership, source.progress, target.title, target.system))
+ self.conn.commit()
+ return (c.rowcount > 0)
+ else:
+ return False
+
+
+class Game(object):
+ """The core data structure of vgstash."""
+ def __init__(self, title, system,
+ ownership=DEFAULT_CONFIG['ownership'],
+ progress=DEFAULT_CONFIG['progress'],
+ notes=""):
+
+ self.title = title
+ self.system = system
+ self.ownership = kvmatch(ownership, OWNERSHIP, DEFAULT_CONFIG['ownership'])
+ self.progress = kvmatch(progress, PROGRESS, DEFAULT_CONFIG['progress'])
+ self.notes = notes
diff --git a/src/vgstash/test_vgstash.py b/src/vgstash/test_vgstash.py
new file mode 100644
index 0000000..74f3c43
--- /dev/null
+++ b/src/vgstash/test_vgstash.py
@@ -0,0 +1,145 @@
+import os
+import pytest
+import vgstash
+import sqlite3
+
+def test_config():
+ assert vgstash.DEFAULT_CONFIG['db_location']
+ assert vgstash.DEFAULT_CONFIG['progress'] in vgstash.PROGRESS.values()
+ assert vgstash.DEFAULT_CONFIG['ownership'] in vgstash.OWNERSHIP.values()
+
+@pytest.fixture(scope="module")
+def vgstash_db():
+ vgstash.DEFAULT_CONFIG['db_location'] = '.vgstash.db'
+ yield vgstash.DB(vgstash.DEFAULT_CONFIG['db_location'])
+ os.remove(vgstash.DEFAULT_CONFIG['db_location'])
+
+def test_game_min():
+ game = vgstash.Game("test_game1", "system3")
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_game_ownership():
+ game = vgstash.Game("test_game2", "system3", 1)
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_game_ownership_str():
+ game = vgstash.Game("test_game2", "system3", 'd')
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_game_progress():
+ game = vgstash.Game("test_game3", "system3", progress=1)
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_game_progress_str():
+ game = vgstash.Game("test_game3", "system3", progress='c')
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_game_notes_no_own_or_progress():
+ game = vgstash.Game("test_game4", "system3", notes="Hello world")
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_game_full():
+ game = vgstash.Game("test_game5", "system3", 'b', 2, "Blah")
+ assert isinstance(game, vgstash.Game)
+ assert isinstance(game.title, str)
+ assert isinstance(game.system, str)
+ assert isinstance(game.ownership, int)
+ assert isinstance(game.progress, int)
+ assert isinstance(game.notes, str)
+ assert game.ownership in vgstash.OWNERSHIP.values()
+ assert game.progress in vgstash.PROGRESS.values()
+
+def test_db(vgstash_db):
+ assert isinstance(vgstash_db.conn, sqlite3.Connection)
+
+def test_db_create_schema(vgstash_db):
+ assert vgstash_db.create_schema()
+
+def test_db_add_game(vgstash_db):
+ game = vgstash.Game("db_add_game", "system")
+ assert vgstash_db.add_game(game)
+ assert vgstash_db.has_game(game)
+
+def test_db_add_game_ownership(vgstash_db):
+ game = vgstash.Game("db_add_game_ownership", "system2", 'p')
+ assert vgstash_db.add_game(game)
+ assert vgstash_db.has_game(game)
+
+def test_db_add_game_notes(vgstash_db):
+ game = vgstash.Game("db_add_game_notes", "system2", '-', '-', 'my notes')
+ assert vgstash_db.add_game(game)
+ assert vgstash_db.has_game(game)
+
+def test_db_update_game(vgstash_db):
+ oldgame = vgstash.Game("db_add_game", "system")
+ newgame = vgstash.Game("db_update_game", "system")
+ if vgstash_db.has_game(oldgame):
+ assert vgstash_db.update_game(oldgame, newgame)
+ assert vgstash_db.has_game(newgame)
+
+def test_db_delete_game(vgstash_db):
+ game = vgstash.Game("db_delete_game", "system2")
+ vgstash_db.add_game(game)
+ assert vgstash_db.delete_game(game)
+
+def test_db_list_games(vgstash_db):
+ res = vgstash_db.list_games()
+ assert isinstance(res, list)
+ assert isinstance(res[0], sqlite3.Row)
+
+def test_db_add_filter(vgstash_db):
+ assert vgstash_db.add_filter("db_add_filter", "SELECT * FROM games WHERE system = 'system2'")
+ assert vgstash_db.has_filter("db_add_filter")
+
+def test_db_update_filter(vgstash_db):
+ assert vgstash_db.update_filter("db_add_filter", "SELECT * FROM games WHERE system='system'")
+ assert vgstash_db.has_filter("db_add_filter")
+ assert "'system'" in vgstash.FILTERS["db_add_filter"]
+
+def test_db_delete_filter(vgstash_db):
+ assert vgstash_db.delete_filter("db_add_filter")
+
+def test_db_list_filters(vgstash_db):
+ assert len(vgstash_db.list_filters()) > 0
diff --git a/src/vgstash/test_vgstash_cli.py b/src/vgstash/test_vgstash_cli.py
new file mode 100644
index 0000000..ac123ef
--- /dev/null
+++ b/src/vgstash/test_vgstash_cli.py
@@ -0,0 +1,73 @@
+import click
+import os
+import pytest
+import vgstash
+import vgstash_cli
+
+from click.testing import CliRunner
+
+verbose = False
+
+def test_init():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.cli, ['init'])
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == "Initializing the database...\nSchema created.\n"
+
+def test_add_minimum():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.cli, ['add', 'Super Mario Bros.', 'NES'])
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == "Added Super Mario Bros. for NES. You physically own it and are playing it.\n"
+
+def test_add_ownership():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.cli, ['add', 'The Legend of Zelda', 'NES', 'd'])
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == "Added The Legend of Zelda for NES. You digitally own it and are playing it.\n"
+
+def test_add_typical():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.cli, ['add', 'Sonic the Hedgehog 2', 'Genesis', '0', '3'])
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == "Added Sonic the Hedgehog 2 for Genesis. You do not own it and have beaten it.\n"
+
+def test_add_full():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.cli, ['add', 'Vectorman', 'Genesis', 'u', 'b', 'beep'])
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == "Added Vectorman for Genesis. You do not own it and have beaten it. It also has notes.\n"
+
+def test_list():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.list_games)
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == '\n'.join((
+ 'Sonic the Hedgehog 2|Genesis|0|3|',
+ 'Vectorman|Genesis|0|3|beep',
+ 'Super Mario Bros.|NES|1|2|',
+ 'The Legend of Zelda|NES|2|2|\n',
+ ))
+
+def test_list_filter():
+ runner = CliRunner()
+ result = runner.invoke(vgstash_cli.cli, ['list', 'playlog'])
+ if verbose:
+ print(result.output)
+ assert result.exit_code == 0
+ assert result.output == '\n'.join((
+ 'Super Mario Bros.|NES|1|2|',
+ 'The Legend of Zelda|NES|2|2|\n',
+ ))
diff --git a/src/vgstash_cli.py b/src/vgstash_cli.py
new file mode 100644
index 0000000..9f5eef4
--- /dev/null
+++ b/src/vgstash_cli.py
@@ -0,0 +1,64 @@
+import vgstash
+import sqlite3
+import click
+import sys
+
+def get_db():
+ """
+ Convenience function to fetch a vgstash DB object from the default location.
+ """
+ return vgstash.DB(vgstash.DEFAULT_CONFIG['db_location'])
+
+
+@click.group('vgstash')
+def cli():
+ pass
+
+
+@cli.command()
+def init():
+ db = get_db()
+ click.echo("Initializing the database...")
+ if db.create_schema():
+ click.echo("Schema created.")
+ else:
+ raise sqlite3.OperationalError("Cannot create schema.")
+
+@cli.command('add')
+@click.argument('title', type=str)
+@click.argument('system', type=str)
+@click.argument('ownership', type=str, required=False, default=vgstash.DEFAULT_CONFIG['ownership'])
+@click.argument('progress', type=str, required=False, default=vgstash.DEFAULT_CONFIG['progress'])
+@click.argument('notes', type=str, required=False, default="")
+def add(title, system, ownership, progress, notes):
+ db = get_db()
+ game = vgstash.Game(title, system, ownership, progress, notes)
+ try:
+ db.add_game(game, update=False)
+ own_clause = (
+ "do not own",
+ "physically own",
+ "digitally own",
+ "digitally and physically own",
+ )
+ progress_clause = (
+ "cannot beat",
+ "haven't started",
+ "are playing",
+ "have beaten",
+ "have completed",
+ )
+ note_clause = "" if len(game.notes) == 0 else " It also has notes."
+ click.echo("Added {} for {}. You {} it and {} it.{}".format(game.title, game.system, own_clause[game.ownership], progress_clause[game.progress], note_clause))
+ except sqlite3.IntegrityError as e:
+ print(e)
+ click.echo("Couldn't add game.")
+
+
+@cli.command('list')
+@click.argument('filter', required=False)
+def list_games(filter="allgames"):
+ db = get_db()
+ res = db.list_games(filter)
+ for r in res:
+ click.echo("{}|{}|{}|{}|{}".format(r['title'], r['system'], r['ownership'], r['progress'], r['notes']))
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..9b397b2
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,13 @@
+# tox (https://tox.readthedocs.io/) is a tool for running tests
+# in multiple virtualenvs. This configuration file will run the
+# test suite on all supported python versions. To use it, "pip install tox"
+# and then run "tox" from this directory.
+
+[tox]
+envlist = py36
+
+[testenv]
+deps =
+ pytest
+commands =
+ pytest -svv
diff --git a/vgstash b/vgstash
deleted file mode 100755
index 365140f..0000000
--- a/vgstash
+++ /dev/null
@@ -1,482 +0,0 @@
-#!/usr/bin/env python3
-
-# TODO: Consider putting all help messages into a dict for easier management
-# TODO: Decide on docstring delimiter and stick to it
-# TODO: refactor
-
-import sys
-import os
-import sqlite3
-import argparse
-import yaml
-import subprocess # to handle the piping use case
-
-__version__ = '0.2'
-
-DB_LOCATION = ''
-OWNERSHIP = 1
-PROGRESS = 1
-TABLE_WIDTH = 80
-
-def safe_print(line):
- try:
- print(line, flush=True)
- # We're catching this in case the other end of a pipe exits early
- except BrokenPipeError:
- sys.stderr.close()
-
-def set_env():
- '''Ensures environment variables are respected. Sets defaults if
- they're not present. If the defaults are not usable, it throws an
- AssertionError and alerts the user.'''
- # This makes outside-scope referencing and assignment possible
- global DB_LOCATION, OWNERSHIP, PROGRESS, TABLE_WIDTH
-
- # Precedence = $VGSTASH_DB_LOCATION, $HOME/.vgstash.db, ./.vgstash.db
- DB_LOCATION = os.getenv('VGSTASH_DB_LOCATION', os.path.join(os.getenv('HOME', '.'), '.vgstash.db'))
-
- # Can't decide what to do with this yet; the first run of vgstash doesn't let you init the db if we uncomment this
- # try:
- # assert(os.path.isfile(DB_LOCATION) and os.path.exists(DB_LOCATION))
- # except AssertionError:
- # print("VGSTASH_DB_LOCATION is not a file. Unset it to fall back to $HOME/.vgstash.db, or correct the environment variable.")
- # sys.exit()
-
- OWNERSHIP = int(os.getenv('VGSTASH_DEFAULT_OWNERSHIP', OWNERSHIP))
- try:
- assert(OWNERSHIP == 0 or OWNERSHIP == 1)
- except AssertionError:
- print("VGSTASH_DEFAULT_OWNERSHIP is not zero or one. Ensure your variable is set correctly.")
- sys.exit()
-
- PROGRESS = int(os.getenv('VGSTASH_DEFAULT_PROGRESS', PROGRESS))
- try:
- assert(PROGRESS >= 0 and PROGRESS <= 3)
- except AssertionError:
- print("VGSTASH_DEFAULT_PROGRESS is not between zero and three. Ensure your variable is set correctly.")
- sys.exit()
-
- TABLE_WIDTH = int(os.getenv('VGSTASH_TABLE_WIDTH', TABLE_WIDTH))
- try:
- assert(TABLE_WIDTH >= 50 or TABLE_WIDTH == 0)
- except AssertionError:
- print("VGSTASH_TABLE_WIDTH must be a number that's zero, or a number that's 50 or greater, to facilitate readability. A value of zero will use your terminal's width.")
- sys.exit()
-
-def init_db(args):
- '''Creates the database schema and the relevant views, preparing it
- for use with vgstash. Will not overwrite an extant database.'''
-
- # Confirm we can create the file
- try:
- assert(os.path.isfile(DB_LOCATION) == False)
- conn = sqlite3.connect(DB_LOCATION)
- print("DB successfully connected!")
- except sqlite3.OperationalError:
- print("The database could not be created and/or connected to.")
- sys.exit()
- except AssertionError:
- print("The database already exists! Delete or move '%s' and init the database again." % DB_LOCATION)
- sys.exit()
-
- # Now let's run a bunch of queries! Fun...
- try:
- conn.execute("CREATE TABLE games (\
- title TEXT NOT NULL,\
- system TEXT NOT NULL,\
- ownership INTEGER NOT NULL DEFAULT 1,\
- progress INTEGER NOT NULL DEFAULT 1\
- );")
- print("Table created.")
- # TODO: Consider executescript()
- conn.execute("CREATE VIEW allgames AS SELECT rowid,* FROM games ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW arcade AS SELECT rowid,* FROM games WHERE ownership >= 1 AND progress = -1 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW backlog AS SELECT rowid,* FROM games WHERE ownership >= 1 AND progress < 2 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW borrowing AS SELECT rowid,* FROM games WHERE ownership = 0 AND progress = 1 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW complete AS SELECT rowid,* FROM games WHERE progress = 3 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW digital AS SELECT rowid,* FROM games WHERE ownership >= 2 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW done AS SELECT rowid,* FROM games WHERE progress >= 2 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW incomplete AS SELECT rowid,* FROM games WHERE ownership >= 1 AND progress = 2 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW new AS SELECT rowid,* FROM games WHERE progress = 0 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW owned AS SELECT rowid,* FROM games WHERE ownership >= 1 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW physical AS SELECT rowid,* FROM games WHERE ownership = 1 OR ownership = 3 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW playlog AS SELECT rowid,* FROM games WHERE ownership >= 1 AND progress = 1 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW unowned AS SELECT rowid,* FROM games WHERE ownership = 0 ORDER BY system,title ASC;")
- conn.execute("CREATE VIEW wishlist AS SELECT rowid,* FROM games WHERE ownership = 0 AND progress = 0 ORDER BY system,title ASC;")
- print("Views created.")
- conn.commit()
- except sqlite3.OperationalError as e:
- print("Query error:", e)
- finally:
- conn.close()
-
-def export_db(args):
- '''Exports a YAML representation of the database to a given file.'''
- try:
- if args.path == None:
- fp = sys.stdout
- else:
- fp = open(args.path, 'w')
- except PermissionError:
- print("The path is not writable by the current user. Please double-check permissions.")
- sys.exit()
- else:
- conn = sqlite3.connect(DB_LOCATION)
- conn.row_factory = sqlite3.Row
- c = conn.cursor()
- fp.write("# vgstash DB file version 0.2\n")
- db_tree = []
- # The .format() call supports the optional -i flag
- for row in c.execute("SELECT {}title,system,ownership,progress FROM games".format("rowid," if args.ids else '')):
- game = {}
- for field in row.keys():
- game.update({field: row[field]})
- db_tree.append(game)
- yaml.dump(db_tree, fp, default_flow_style=False, indent=4, allow_unicode=True)
- fp.close()
- if fp is not sys.stdout:
- print("Database exported to {}.".format(args.path))
-
-def import_db(args):
- '''Read yaml lines from a file (or stdin) to add to the database.'''
- try:
- if args.path == None:
- fp = sys.stdin
- else:
- fp = open(args.path, 'r')
- except PermissionError:
- print("The path is not readable by the current user. Please check permissions.")
- sys.exit()
- # Time for business! :D
- records = 0
- data = yaml.safe_load(fp)
- conn = sqlite3.connect(DB_LOCATION)
- conn.row_factory = sqlite3.Row
- c = conn.cursor()
- # List comprehensions are why I like Python.
- # inline conditionals are badass, too
- c.executemany("INSERT {}INTO games ({}title,system,ownership,progress) VALUES ({}:title, :system, :ownership, :progress)".format("OR REPLACE " if args.ids else "", "rowid," if args.ids else "", ":rowid," if args.ids else ""), (game for game in data))
- conn.commit()
- conn.close()
- print("Imported {} games.".format(c.rowcount))
-
-def add_game(args):
- '''Adds a game to the database. Requires at least a title and
- system.
-
- Ownership can be 0 to 3:(not owned) or 1 (owned). The default is 1.
-
- Completion can be -1 to 3:
- -1 (unbeatable)
- 0 (fresh, never played)
- 1 (in-progress) (default)
- 2 (beaten)
- 3 (completed 100%)
- '''
- if args.ownership == '-':
- args.ownership = OWNERSHIP
- if args.progress == '-':
- args.progress = PROGRESS
- # Translate our args so they can be added and reflected correctly
- args.ownership = translate_ownership(args.ownership)
- args.progress = translate_progress(args.progress)
-
- conn = sqlite3.connect(DB_LOCATION)
- game = (args.title, args.system, args.ownership, args.progress)
- conn.execute("INSERT INTO games VALUES(:title, :system, :ownership, :progress)", game)
- conn.commit()
- conn.close()
- qual = (
- "don't own it",
- "own it physically",
- "own it digitally",
- "own it physically and digitally"
- )
- comp = {
- -1: "it's unbeatable",
- 0: "it's new",
- 1: "you're playing it",
- 2: "it's been beaten",
- 3: "it's been completed"
- }
- print("Added {0} for {1}. You {2} and {3}.".format(args.title, args.system, qual[int(args.ownership)], comp[int(args.progress)]))
-
-def delete_game(args):
- '''Removes a game from the database.'''
- target = game_found(args.id)
- if target != None:
- try:
- conn = sqlite3.connect(DB_LOCATION)
- c = conn.cursor()
- conn.execute("DELETE FROM games WHERE rowid = :id", {'id': args.id})
- conn.commit()
- print("Removed {0} for {1} from your database.".format(target[0], target[1]))
- except sqlite3.OperationalError:
- print("Could not remove game from the database. Check the file and ensure you have write access.")
- finally:
- conn.close()
- else:
- print("That game ID does not exist in the database.")
-
-def translate_progress(arg):
- '''Translate a letter progress value into a numeric value for the database.'''
- try:
- if len(arg) != 1:
- print("Argument must be a single character.")
- sys.exit()
- if not arg.isnumeric():
- vals = {
- 'u': -1,
- 'n': 0,
- 'p': 1,
- 'b': 2,
- 'c': 3,
- }
- try:
- return vals[arg]
- except KeyError:
- # Note to self, doubling a brace escapes it. This was tucked away in the Python docs...
- print("Value '{}' not valid. Try one of {{{}}}.".format(arg, ', '.join(vals)))
- sys.exit()
- else:
- return arg
- except TypeError:
- return arg
-
-def translate_ownership(arg):
- '''Translate a letter ownership value into a numeric value for the database.'''
- try:
- if len(arg) != 1:
- print("Argument must be a single character.")
- sys.exit()
- if not arg.isnumeric():
- vals = {
- 'n': 0,
- 'p': 1,
- 'd': 2,
- 'b': 3
- }
- try:
- return vals[arg]
- except KeyError:
- print("Value '{}' not valid. Try one of {{{}}}.".format(arg, ', '.join(vals)))
- sys.exit()
- else:
- return arg
- except TypeError:
- return arg
-
-def update_game(args):
- '''Changes a specific field for a game. Mostly used for correcting
- mistakes and updating game status as it changes.'''
- target = game_found(args.gid)
- # Let's accept anything remotely matching the field names
- opts = ['system', 'title', 'ownership', 'progress']
- for i in opts:
- if i.startswith(args.field):
- args.field = i
- if not (args.field in opts):
- print("Invalid field name indicated! Please choose from {}".format(', '.join(opts)))
- sys.exit()
- # Translate y, n, f, i, b, and c
- if args.field == 'ownership':
- args.value = translate_ownership(args.value)
- if args.field == 'progress':
- args.value = translate_progress(args.value)
- # We need this workaround because execute() doesn't like variable column names
- update_stmt = "UPDATE games SET {} = :val WHERE rowid = :id".format(args.field)
- conn = sqlite3.connect(DB_LOCATION)
- c = conn.cursor()
- c.execute(update_stmt, {'id': args.gid, 'val': args.value})
- conn.commit()
- if c.rowcount == 1:
- own_msg = ('not owned', 'physically owned', 'digitally owned', 'physically and digitally owned')
- prog_msg = {
- -1: 'unbeatable',
- 0: 'new',
- 1: 'playing',
- 2: 'beaten',
- 3: 'complete'
- }
- update_msg = {
- 'title': "{ot} on {os} is now named {val}.",
- 'system': "{ot} on {os} is now on {val}.",
- 'ownership': "{ot} on {os} is now marked {val}.",
- 'progress': "{ot} on {os} is now marked {val}."
- }
- print(update_msg[args.field].format(ot = target['title'], os = target['system'], val = args.value if args.field == 'title' or args.field == 'system' else own_msg[int(args.value)] if args.field == 'ownership' else prog_msg[int(args.value)]))
- else:
- print("Could not update game information. Check the DB's permissions.")
- conn.close()
-
-def list_games(args):
- '''Filters games according to preset queries. Internally,
- they are SQLite VIEWs
-
- Each filter targets different games:
-
- all: everything, sorted by system then title
- backlog: * have not been beaten or completed
- complete: have been completed
- done: are beaten or complete
- fresh: haven't been started yet
- incomplete: * beaten, but not completed
- owned: are marked as 'owned'
- unowned: are marked as 'unowned'
- wishlist: are both 'unowned' and 'fresh' or 'in-progress'
-
- * Will only target games that are owned.
- '''
- if args.filter == 'all':
- args.filter = 'allgames'
- # DBs don't allow table names to be parameterized. This little hack
- # works around that limitation.
- conn = sqlite3.connect(DB_LOCATION)
- conn.row_factory = sqlite3.Row
- select_stmts = {
- 'allgames': "SELECT * FROM allgames",
- 'arcade': "SELECT * FROM arcade",
- 'backlog': "SELECT * FROM backlog",
- 'borrowing': "SELECT * FROM borrowing",
- 'complete': "SELECT * FROM complete",
- 'digital': "SELECT * FROM digital",
- 'done': "SELECT * FROM done",
- 'incomplete': "SELECT * FROM incomplete",
- 'new': "SELECT * FROM new",
- 'owned': "SELECT * FROM owned",
- 'physical': "SELECT * FROM physical",
- 'playlog': "SELECT * FROM playlog",
- 'unowned': "SELECT * FROM unowned",
- 'wishlist': "SELECT * FROM wishlist",
- }
- # We're emulating a do-while loop
- first_pass = True
- for row in conn.execute(select_stmts[args.filter]):
- if args.raw:
- # Use this for raw output, for use with other tools
- print('|'.join(map(str,row)))
- continue
- else:
- row_format(row, header=first_pass)
- first_pass = False
-
-
-def game_found(gid):
- '''Confirms the given ID is in the database and, if found, returns it.
- Used in conjunction with delete_game() and update_game().'''
- conn = sqlite3.connect(DB_LOCATION)
- # Give us access to .keys()
- conn.row_factory = sqlite3.Row
- c = conn.cursor()
- c.execute("SELECT title,system,ownership,progress FROM games WHERE rowid = :id", {'id': gid})
- row = c.fetchone()
- conn.close()
- return row
-
-def row_format(args, header):
- # There's another way to do this, involving gathering the entire results and *then* formatting them
- # That is incredibly wasteful of resources imo, so it may need some testing to see if it's better.
- # Ideally, we'd only make the table as wide as needed; that can't happen unless we know the longest
- # title's length...
- # Get our maximum width so we can toy around with things.
- if TABLE_WIDTH >= 50:
- maxwidth = TABLE_WIDTH
- else:
- try:
- maxwidth = os.get_terminal_size().columns
- except OSError:
- with open('/dev/tty') as tty:
- curwidth = int(subprocess.check_output(['stty', 'size']).split()[1])
- maxwidth = curwidth
- twidth = maxwidth - 37
- if header == True:
- print("{:^4s} | {:<{w}s} | {:<8s} | {:^3s} | {:<7s}".format("ID", "Title", "System", "Own", "Status", w=twidth))
- print("-" * maxwidth)
-
- gidstr = "{: >4d}".format(args['rowid'])
- titlestr = "{: <{w}s}".format(args['title'][:twidth], w=twidth)
- systemstr = "{: ^8s}".format(args['system'][:8])
- ownltr = [' ', 'P', ' D', 'P D']
- ownstr = "{: <3s}".format(ownltr[args['ownership']])
- statltr = {
- -1: 'U',
- 0: 'N',
- 1: 'P',
- 2: 'B',
- 3: 'C'
- }
- statstr = "{: <7s}".format((" " * args['progress'] * 2) + statltr[args['progress']])
- """
- ID | Title | System | Own | Status
- ---------------------------------------------------
- 1234 | This is a title | Wii U VC | * | U N P B C
- """
- safe_print(" | ".join((gidstr, titlestr, systemstr, ownstr, statstr)))
-
-
-def main():
- # Establish our important variables
- set_env()
-
- # Set up the command parsers
- parser = argparse.ArgumentParser()
- subparsers = parser.add_subparsers()
-
- # 'add' command
- parser_add = subparsers.add_parser('add', help="Add a game to your database")
- parser_add.add_argument('title', type=str, help="The title of the game you're adding")
- parser_add.add_argument('system', type=str, help="The system your game is for")
- parser_add.add_argument('ownership', type=str, nargs='?', default='-', help="0 or 'n' for no, 1 or 'y' for yes. Defaults to {0}. Accepts '-' for default, in case you want to set progress but not ownership".format(OWNERSHIP))
- parser_add.add_argument('progress', type=str, nargs='?', default='-', help="Indicates completion level. 0 = [f]resh, never played; 1 = [i]n progress; 2 = [b]eaten; 3 = [c]ompleted. Defaults to {0}".format(PROGRESS))
- parser_add.set_defaults(func=add_game)
-
- # 'init' command
- parser_init = subparsers.add_parser('init', help="Initialize the database so it can be used")
- parser_init.set_defaults(func=init_db)
-
- # 'delete' command
- parser_del = subparsers.add_parser('delete', help="Remove a game from your database")
- parser_del.add_argument('id', type=int, help="The ID of the game to be deleted")
- parser_del.set_defaults(func=delete_game)
-
- # 'list' command
- parser_list = subparsers.add_parser('list', help="List your games with preset views")
- list_filters = ['all', 'arcade', 'backlog', 'borrowing', 'complete', 'digital', 'done', 'incomplete', 'new', 'owned', 'physical', 'unowned', 'wishlist', 'playlog']
- parser_list.add_argument('filter', nargs='?', choices=list_filters, default='all', help='Filter games accerding to preset queries. Valid filters: {}'.format(', '.join(list_filters)))
- parser_list.add_argument('-r', '--raw', action="store_true", help="Output the list in a machine-readable format, separated by pipe characters.")
- parser_list.set_defaults(func=list_games)
-
- # 'update' command
- parser_up = subparsers.add_parser('update', help="Update a specific game")
- parser_up.add_argument('gid', type=int, help="The game's ID (found in 'vgstash list' output)")
- parser_up.add_argument('field', help="The field you wish to update")
- parser_up.add_argument('value', type=str, help="The new value for the field")
- parser_up.set_defaults(func=update_game)
-
- # 'export' command
- parser_ex = subparsers.add_parser('export', help="Export the database's information to a YAML file.")
- parser_ex.add_argument('path', nargs='?', default=None, help="The full path of the file you want to write to. Defaults to stdout")
- parser_ex.add_argument('-i', '--ids', action="store_true", help="Export game IDs along with game information. Useful if preparing a batch update.")
- parser_ex.set_defaults(func=export_db)
-
- # 'import' command
- parser_im = subparsers.add_parser('import', help="Import a YAML file's content into the database.")
- parser_im.add_argument('path', nargs='?', default=None, help="The full path of the file you want to read from. Defaults to stdin")
- parser_im.add_argument('-i', '--ids', action="store_true", help="Update via game IDs in the database instead of appending")
- parser_im.set_defaults(func=import_db)
-
- # Let'er rip!
- args = parser.parse_args()
- try:
- args.func(args)
- except AttributeError:
- # Handle "no arguments" case
- args = ['-h']
- parser.parse_args(args)
- args.func(args)
- # Oddity in Python needed if you're going to play nice with piping
- sys.stderr.close()
-
-# Our usual incantation
-if __name__ == "__main__":
- main()