Welcome to Command Line Achievements’s documentation!¶
Command line was all fun when it was invented fifty years ago, but it can no longer compete with modern interfaces, glowing windows, shiny buttons, and so on… Command Line Achievements (later abbreviated CLAchievements or CLA) aims to solve this, by making command line fun again! Inspired by video game achievements, it unlocks achievements when users meets the fulfillement conditions.
Warning
CLAchievements is not mature yet. Let’s say it is a proof of concept (a silly concept, but still a concept).
Before using this software, please read the warning.
Contents:
Install and Enable¶
Warning
Installing CLAchievements is not enough: it has to be enabled.
Install¶
PyGObject¶
CLAchievements uses PyGObject to display the achievements (other methods might be supported later). Thus, it must be installed, either system-wide (if CLAchievements is not installed in a virtualenv, or if the virtualenv has been created with option --system-site-packages
), or as a dependency (see the extra pgi
dependency below).
From sources¶
Install (in a virtualenv, if you do not want to mess with your distribution installation system):
python3 setup.py install
Or, to install the
pgi
dependency as well:python3 setup.py install[pgi]
From pip¶
Use:
pip install clachievements
Or, if you need the pgi
dependency as well:
pip install clachievements[pgi]
Quick and dirty Debian (and Ubuntu?) package¶
This requires stdeb to be installed:
python3 setup.py --command-packages=stdeb.command bdist_deb
sudo dpkg -i deb_dist/clachievements-<VERSION>_all.deb
The PyGObject
dependency is proposed as an optional requirement.
Enable¶
Once CLAchievements is installed, it does not work yet. Running ls
will not trigger any achievement: you will to wrap it using CLAchievements by running clachievements run ls
.
Replacing ls
by clachievements run ls
will change your habits. You do not want it. So, it should be aliased: alias ls="clachievements run ls"
.
All the commands triggering achievements should be aliased. To ease this, the clachievements command provides a sub-command clachievements alias
, which display the shell code generating all the required aliases. Thus, in your .bashrc
(or .watheverrc
), write the line $(clachievements alias)
to enable every aliases.
Check¶
To check if CLAchievements is enabled, run ls
in a terminal. If you see the So it begins…
achievement unlocked, it works. Otherwise, it does not… yet.
If you are not sure about wether CLAchievements works or not, reset the achievements using clachievements reset
, and run ls
again to test it.
Usage¶
Here are the command line options for clachievements.
Various tools for Command Line Achievements.
usage: clachievements [-h] [--version] {alias,report,reset,run} ...
Named Arguments¶
- --version
Show version
Subcommands¶
List of available subcommands.
- subcommand
Possible choices: alias, report, reset, run
Sub-commands¶
alias¶
Display code to create aliases.
clachievements alias [-h]
report¶
Display a progress summary.
clachievements report [-h]
reset¶
Reset progress.
clachievements reset [-h]
run¶
Run binaries, unlocking achievements if relevant.
clachievements run [-h]
Write your own achievement¶
Achievement without persistent data¶
Suppose you want to create an achievement Foo
awarded when user successfully run a command on a file foo
. Let’s write this achievement.
Meta-information¶
First, we need to define a class and define meta-information: any achievement is a subclass of Achievement
. Two arguments are compulsory:
title
: ifNone
, your class is an abstract achievement, meant to be subclassed; if a string, your achievement is an actual achievement. See theclass documentation
for other attributes;description: your achievement must have a description. The first non-empty line of your class docstring is used, unless
_description
is defined, when it is used instead.
See the class documentation
to get more information about other attributes.
from clachievements.achievements import Achievement
from clachievements.testutils import test_lock, test_unlock
class Foo(Achievement):
"""Successfully run a command on file `foo`."""
title = "Foo"
Unlocking the achievement¶
Great: you have an achievement. But it is never unlocked: it will be frustrating for the user.
An achievement is a context manager: its __enter__()
and __exit__()
methods are called before and after the actual system call. They can be used to test the command line, the environment before and after the command, etc.
Here, we test that:
foo
is a positional argument;the command did not fail.
If so, we call unlock()
to unlock the argument. It ensures that the argument is marked as unlocked, and it displays a pop-up to notify the user. No need to make sure that parallel calls to your achievement might unlock it at the same time: it is handled within the unlock()
method itself.
from clachievements.achievements import Achievement
from clachievements.testutils import test_lock, test_unlock
class Foo(Achievement):
"""Successfully run a command on file `foo`."""
title = "Foo"
def __exit__(self, exc_type, exc_value, traceback):
if "foo" in self.command.positional:
if isinstance(exc_value, SystemExit):
if exc_value.code == 0:
self.unlock()
Testing¶
If we are done, the achievement will work, but the unit tests will fail. An achievement must define a test that unlock the achievement.
Each achievement must define a static or class method, decorated with test_unlock()
. This method must iterate strings which are shell commands, unlocking the achievement. To be wrapped by CLAchievements, system calls must use string substitution: "foo bar"
will call the foo
binary, not wrapped by CLAchievements, where "{bin.foo} bar"
will call the foo
binary, wrapped by CLAchievements.
You can add as many test methods as you want. You can also define test methods that must not unlock achievements, by decorating them with test_lock()
.
When performing tests, each test method is run inside an empty temporary directory, which will be deleted afterward.
from clachievements.achievements import Achievement
from clachievements.testutils import test_lock, test_unlock
class Foo(Achievement):
"""Successfully run a command on file `foo`."""
title = "Foo"
def __exit__(self, exc_type, exc_value, traceback):
if "foo" in self.command.positional:
if isinstance(exc_value, SystemExit):
if exc_value.code == 0:
self.unlock()
@staticmethod
@test_unlock
def test_touch():
yield "{bin.touch} foo"
@staticmethod
@test_lock
def test_ls():
yield "{bin.ls} foo"
Achievement with persistent data¶
Now, we want a new achievement FooBar
to be triggered when 50 successful commands have been run on a file foo
. Let’s do this.
To do this, we have to store the number of successful commands. A class is defined to ease this process: SimplePersistentDataAchievement
. It is wrong (see below), but is works for simple cases.
When using this class, a row is created in the CLAchievements database with this achievement name.
The first time this achievement is created, this row is filled with the content of attribute
default_data
.When accessing to
data
, data is read from the database.When assigning a value to
data
, data is written to the database.
Any picklable
data can be stored using this method.
This is simple, but this is not robust to concurrent access: if an integrity error occurs when assigning a value to data
, it is silently ignored.
With this example achievement, if I run this argument 50 times in parallel, about 30 of the assignments are ignored. If I were to design a life critical application, this would be a big issues. But this is only a game: it does not work perfectly, but it is so much simpler to implement!
from clachievements.achievements import SimplePersistentDataAchievement
from clachievements.testutils import test_lock, test_unlock
class FooBar(SimplePersistentDataAchievement):
"""Successfully run 50 command on file `foo`."""
title = "FooBar"
default_data = 0
def __exit__(self, exc_type, exc_value, traceback):
if "foo" in self.command.positional:
if isinstance(exc_value, SystemExit):
if exc_value.code == 0:
self.data += 1
if self.data >= 50:
self.unlock()
@staticmethod
@test_lock
def test_touch():
for _ in range(49):
yield "{bin.touch} foo"
@staticmethod
@test_unlock
def test_ls_touch():
for _ in range(25):
yield "{bin.touch} foo"
yield "{bin.ls} foo"
More¶
Suppose this error-prone persistent data management does not suit you. Just write your own: within the achievement, the sqlite3 database connection
is available as self.database.conn
. Do whatever you want with it (without breaking other plugin databases)!
In this case, to be sure not to mess with tables of CLA core or other plugins, use the tables named (case insensitive) achievement_YourPluginName
or achievement_YourPluginName_*
.
Methods first()
and last()
can be used to initialize or clean the achievement: the first one is called the first time the achievement is ever loaded (so it can be used to create some tables into the database), while the last one is called when the achievement has just been unlocked (so it can be used to clean stuff). Both these methods are meant to be subclassed, and are expected to call super().first(...)
at the beginning of their code.
Modules¶
Command¶
- class Command(argv)¶
Parse command line call, for easy access to parameters.
Warning
This class does not work, on purpose. Correctly parsing command line depends on each command, and implementing it correctly would mean re-implementing the parsing process used by every binary that is to be wrapped with CLAchievements. This is not going to happen. What is done is:
Any argument not starting with
-
is a positional argument.Any argument starting with a single
-
is a list of short options (-foo
is equivalent to-f -o -o
). Those options do not have any arguments.Any argument starting with double
--
is a long option. If it contains a=
, it is intepreted as an option with its argument; otherwise, it does not have any arguments.
The available attributes are:
- bin¶
Base name of the wrapped binary (more or less equivalent to
os.path.basename(sys.argv[0])
).
- short¶
multidict.MultiDict
of short command line arguments (that is, arguments starting with a single-
). Keys are the arguments, and values are the options to the arguments. See the warning at the beginning of the documentation of this class.
- long¶
multidict.MultiDict
of long command line arguments (that is, arguments starting with a double-
). Keys are the arguments, and values are the options to the arguments. See the warning at the beginning of the documentation of this class.
- positional¶
List of positional arguments (that is, arguments not starting with -).
The following doctest serves as an example.
>>> command = Command("/usr/bin/foo tagada -bar --baz --baz=plop tsoin tsoin".split()) >>> command.bin 'foo' >>> command.short <MultiDict('b': None, 'a': None, 'r': None)> >>> command.long <MultiDict('baz': None, 'baz': 'plop')> >>> command.argv ['/usr/bin/foo', 'tagada', '-bar', '--baz', '--baz=plop', 'tsoin', 'tsoin'] >>> command.positional ['tagada', 'tsoin', 'tsoin']
Achievements¶
- class Achievement(command, database)¶
Achievement: Something that is unlocked when user perform the right commands.
A how-to is available in the Write your own achievement section, which illustrates this class documentation.
This class is a context manager. The
__enter__()
method is called before the actual wrapped command call, and the__exit__()
method is called after it. One of those method must callunlock()
when the conditions to fulfill the achievement are met.- _description = None¶
Description of the achievement. If None, the first non-empty line of the class docstring is used instead.
- bin = None¶
List of binaries loading this achievement. If None, this achievement is always loaded.
- first()¶
This method is called once: when this achievement is loaded for the first time.
This method is meant to be subclassed.
- icon = 'star.svg'¶
File name of the icon (relative to the data directory).
- last()¶
This method is called once: when this achievement has just been unlocked.
This method is meant to be subclassed.
- title = None¶
Title of the achievement. If None, the class is an abstract achievement, to be subclassed.
- unlock()¶
Called when achievement is unlocked.
Mark this achievement as unlocked in the database.
Notify user.
This method is to be called by one of the
__enter__()
or__exit__()
method when the conditions to unlock the achievement are fulfilled.
- class SimplePersistentDataAchievement(command, database)¶
Achievement, with a simple way to store data into a database.
It is very simple to use, since accessing or writing to
self.data
will automatically read or write data from the database.But the cost is that concurrent access to the database will lead to errors. For instance, on a test, running fifty concurrent calls to
self.data += 1
only incrementedself.data
by about twenty values.This is wrong but:
this is just a game, so there is no important consequence to this error;
this is a very simple class. If you want a more robust one, please provide a patch.
- property data¶
Picklable
persistent data, specific to this achievement.Note
Database is not locked when reading or writing this data. That is, concurrent runs of
self.data += 1
are not guaranteed to succeed.Note
Be careful to call
self.data = MY_NEW_DATA
to store your updated data. This means that, ifself.data
is a dictionary,self.data.update({"foo": "bar"})
will not store anything.
- default_data = None¶
Data stored as this achievement persistent data when this achievement is met for the first time.
Test utils¶
- test_lock(func)¶
Decorator for test methods keeping the achievement locked.
To be applied to methods of
Achievement
.Those methods must iterate over shell commands (as strings). Executing those commands must not unlock the achievement. Otherwise, the corresponding test will fail.
- test_unlock(func)¶
Decorator for test methods unlocking the achievement.
To be applied to methods of
Achievement
.Those methods must iterate over shell commands (as strings). Executing those commands must unlock the achievement. Otherwise, the corresponding test will fail.
Does it work?¶
I would say CLAchievements works if user cannot distinguish wrapped commands from original commands, excepted the Achievement unlocked text popping up from times to times.
What works¶
When the command ends on its own, the standard input, and standard and error output are transmitted to the wrapped command, and the return code of the command is the expected one (the one of the wrapped command).
What does not work¶
Interruptions: When a command is interrupted (by using Ctrl-C or kill), user sees the internals of CLAchievements.
$ cat ^CTraceback (most recent call last): File "/home/louis/.virtualenvs/clachievements/bin/clachievements", line 9, in <module> load_entry_point('clachievements==0.1.0', 'console_scripts', 'clachievements')() (...) File "/usr/lib/python3.5/subprocess.py", line 1608, in _try_wait (pid, sts) = os.waitpid(self.pid, wait_flags) KeyboardInterrupt $
It should act as if the wrapped command had been interrupted.
It is incredibly slow: for instance, running one thousand ls is about 600 times slower using CLAchievements than using the original ls.
$ time bash -c 'for i in $(seq 1000); do clachievements run ls > /dev/null; done' real 7m57.569s user 6m3.960s sys 0m21.536s $ time bash -c 'for i in $(seq 1000); do ls > /dev/null; done' real 0m0.790s user 0m0.024s sys 0m0.080s
This is a real problem, and addressing it might mean rewriting everything from scratch…