diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dd27ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### Vim ### +# swap +.sw[a-p] +.*.sw[a-p] +# session +Session.vim +# temporary +.netrwhist +# auto-generated tag files +tags + +*.log diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..40acfb1 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,536 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns=test_.*.py + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + locally-enabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + cell-var-from-loop + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=colorized + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + ex, + Run, + id, + db, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +#method-rgx= + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0bfe8f5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.rst tox.ini Makefile LICENSE.txt + +global-exclude *.py[co] diff --git a/Makefile b/Makefile index a5e6e2a..af2ecad 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,107 @@ -include config.mk +MAIN_PACKAGE := statnot +.PHONY: all +all: build + +.PHONY: init +init: + @pip install -r requirements.txt + +.PHONY: install install: - @echo installing executable file to ${DESTDIR}${PREFIX}/bin - @mkdir -p ${DESTDIR}${PREFIX}/bin - @cp -f statnot ${DESTDIR}${PREFIX}/bin - @chmod 755 ${DESTDIR}${PREFIX}/bin/statnot - @touch ~/.statusline.sh - -uninstall: - @echo removing executable file from ${DESTDIR}${PREFIX}/bin - @rm ${DESTDIR}${PREFIX}/bin/statnot + @./setup.py install --optimize=1 --record=install.log + +.PHONY: build +build: + @./setup.py build + +.PHONY: check +check: + @echo "────────────────── flake8 ──────────────────" + @flake8 --show-source --statistics $(MAIN_PACKAGE) + @echo "────────────────────────────────────────────" + @echo + @echo + @echo "────────────────── pylint ──────────────────" + @pylint $(MAIN_PACKAGE) + @echo "────────────────────────────────────────────" + +.PHONY: release +release: clean + @./setup.py sdist upload + @./setup.py bdist_wheel upload + +.PHONY: dist +dist: clean + @./setup.py sdist + @./setup.py bdist_wheel + +.PHONY: docs +docs: + @sphinx-apidoc -o docs/ $(MAIN_PACKAGE) + @$(MAKE) -C docs clean + @$(MAKE) -C docs html + +.PHONY: test +test: + @./setup.py test + +.PHONY: test-all +test-all: + @tox + +.PHONY: tox +tox: test-all + +.PHONY: coverage +coverage: + @coverage run --source $(MAIN_PACKAGE) -m py.test + @coverage report + @coverage html + +.PHONY: clean +clean: clean-bytecode clean-coverage clean-eggs \ + clean-dist clean-build clean-tox clean-cache \ + clean-dev clean-install + +.PHONY: clean-install +clean-install: + @rm -f install.log + +.PHONY: clean-bytecode +clean-bytecode: + @find . -name '*.pyc' -type f -delete + @find . -name '*.pyo' -type f -delete + @find . -name '*~' -type f -delete + @find . -name '__pycache__' -type d -exec rm -fr {} + + +.PHONY: clean-coverage +clean-coverage: + @rm -rf htmlcov + @rm -f .coverage + +.PHONY: clean-eggs +clean-eggs: + @rm -rf .eggs + @rm -rf *.egg-info + +.PHONY: clean-dist +clean-dist: + @rm -rf dist + +.PHONY: clean-build +clean-build: + @rm -rf build + +.PHONY: clean-tox +clean-tox: + @rm -rf .tox + +.PHONY: clean-cache +clean-cache: + @rm -rf .cache + +.PHONY: clean-dev +clean-dev: + @find . -name '.mypy_cache' -type d -exec rm -fr {} + + @find . -name '.ropeproject' -type d -exec rm -fr {} + diff --git a/README.md b/README.md deleted file mode 100644 index 0466991..0000000 --- a/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# statnot -statnot is a [notification-daemon](http://www.galago-project.org/news/index.php) replacement for lightweight window managers like [dwm](http://dwm.suckless.org) and [wmii](http://wmii.suckless.org). It receives and displays notifications from the widely used [Desktop Notifications](http://www.galago-project.org/specs/notification/0.9/index.html) speficiation. - -* [source repository](http://github.com/halhen/statnot/) - -Distribution specific links: - -* [archlinux AUR package](http://aur.archlinux.org/packages.php?ID=25528) - -## Background -In some lightweight window managers, the text in the status bar is fed from an external process. For example dwm (version 5.4 and above) reads the status message from the X root window name, set by `xsetroot -name `. A user typically enters a loop like below in .xinitrc to keep the status bar updating. - - while true - do - xsetroot -name "$(date +"%F %R")" - sleep 30s # update every thirty seconds - done & - -This solution works well for status messages that are managed from a single point, for example when printing the same information every time. statnot lets you combine regular status messages with Desktop Notifications in a straightforward way. - -### Desktop Notifications -If you have used a "regular" window manager like KDE or Gnome, you have probably come across notifications. The are typically small windows with text messages and sometimes an icon that shows for a couple of seconds before they fade out. They are for example used to let the user know that a new instant message has arrived or that the battery is running low. Desktop Notifications is a specification created for freedesktop.org that many applications use. For example Pidgin and Evolution can be configured to notify for new messages using Desktop Notifications. - -## Installation -*Note: if you install statnot through a package manager, some of these steps have been taken care for you. You probably need to edit the .xinitrc yourself, see below.* - -To install statnot, first install the required dependencies: - -* [python 2.5+](http://www.python.org) -* [dbus-python](http://dbus.freedesktop.org/releases/dbus-python/) -* [pygtk](http://www.pygtk.org/) - (not for GUI support, but the dbus-python library requires it) - -Next, adjust the target directories in the `config.mk` file to fit your setup. - -To install, run as root: - - # make install - -Finally, statnot needs to start with the window manager. You can for example add the following to .xinitrc: - - killall notification-daemon &> /dev/null - statnot & - -Note that the statnot needs to be the only notification tool running. The example above makes sure that `notification-daemon` is not running. - -## Configuration -The major, likely only, part you want to configure in statnot is what the status message should look like. During installation, a file called `.statusline.sh` is created in `$HOME/`. This gets called with regular intervals to retrieve the text that should be printed on the status line. statnot reads STDOUT, so a simple `echo ` is a good way to return the text. - -During normal status updates, .statusline.sh is called without parameters. Here, you typically fetch and `echo` information about the computers performance, battery level or current time. - -Any pending notification is passed as the first argument to .statusline.sh. This way you can include additional information to the actual notification. - -Below is an example of .statusline.sh. It prints something like `[load 0.12 0.10 0.7] 11:42` in the status bar. When there is a pending notification, it prints `NOTIFICATION: `. - - if [ $# -eq 0 ]; then - loadavg="`cat /proc/loadavg | awk '{print $1, $2, $3}'`"; - echo "[load ${loadavg}] `date +'%R'`"; - else - echo "NOTIFICATION: $1"; - fi - -For more advanced configuration, a configuration file can be passed to statnot on the command line, which overrides the default settings. This configuration file must be written in valid python, which will be read if the filename is given on the command line. You do only need to set the variables you want to change, and can leave the rest out. - -Below is an example of a configuration which sets the defaults. - - # Default time a notification is show, unless specified in notification - DEFAULT_NOTIFY_TIMEOUT = 3000 # milliseconds - - # Maximum time a notification is allowed to show - MAX_NOTIFY_TIMEOUT = 5000 # milliseconds - - # Maximum number of characters in a notification. - NOTIFICATION_MAX_LENGTH = 100 # number of characters - - # Time between regular status updates - STATUS_UPDATE_INTERVAL = 2.0 # seconds - - # Command to fetch status text from. We read from stdout. - # Each argument must be an element in the array - # os must be imported to use os.getenv - import os - STATUS_COMMAND = ['/bin/sh', '%s/.statusline.sh' % os.getenv('HOME')] - - # Always show text from STATUS_COMMAND? If false, only show notifications - USE_STATUSTEXT=True - - # Put incoming notifications in a queue, so each one is shown. - # If false, the most recent notification is shown directly. - QUEUE_NOTIFICATIONS=True - - # update_text(text) is called when the status text should be updated - # If there is a pending notification to be formatted, it is appended as - # the final argument to the STATUS_COMMAND, e.g. as $1 in default shellscript - - # dwm statusbar update - import subprocess - def update_text(text): - # Get first line - first_line = text.splitline()[:-1] - subprocess.call(["xsetroot", "-name", first_line]) - -## Possible errors -If no status message shows, verify that statnot is running. Also make sure your $HOME/.statusline.sh works and prints properly. - -If notifications are not shown, make sure that no other notification-daemon is running. `killall notification-daemon` is a good command to try. Restart statnot if there was another daemon running. Also make sure that .statusline.sh takes care of and prints the $1 parameter (see section Configuration). - -## Supported software -More and more applications use Desktop Notifications. Use [Google](http://www.google.com) to find solutions for your applications. `libnotify` is a good term to search, since it is a common library used by many applications. - -You can also send your own notifications to statnot. This is easily done with the `notify-send` command. For example, `notify-send "Hello World"` will print `Hello World` in the status bar according to your speficiation. This is useful to notify that a long running task like a download or software build has finished. - -notify-send can also be used for other, more direct messages. For exampe, I call a script called `dwm-volume` when my volume media buttons on the keyboard are pressed. This script adjusts the volume and sends a notification containing e.g. `vol [52%] [on]`. - - #!/bin/sh - if [ $# -eq 1 ]; then - amixer -q set Master $1 - fi - notify-send -t 0 "`amixer get Master | awk 'NR==5 {print "vol " $4, $6}'`" - -As you can see, I use the option `-t 0` to notify-send, i.e. I request that the notification should show for zero milliseconds. For statnot, this means that the message should show for a regular status tick, by default two seconds, but if other notifications arrive, like a second press on the volume button, it goes away. This setup allows my audio volume to show only when I change it, while it updates instantly when I press the media buttons. Note that this option becomes useless if QUEUE_NOTIFICATIONS is set to False. - -## Final notes -I'm sure there are other ways to use statnot. For example, one can create an update_text() that sends notifications as e-mail or instant messages, or that stores them to a log file. If you create any cool applications with statnot, I'd be happy to hear about them. - -If you are interested in more examples, my [dotfiles, including .statusline.sh](http://github.com/halhen/dotfiles/tree/master) and [dwm configuration](http://github.com/halhen/dwm/tree/master) are available on [github](http://github.com/halhen). - -Released under the GPL. Please report any bugs or feature requests by email. Also, please drop me a line to let me know you like and use this software. - -Authors: - * Henrik Hallberg (); halhen@github - * Olivier Ramonat; enzbang@github diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..0c969bc --- /dev/null +++ b/README.rst @@ -0,0 +1,239 @@ +statnot +======= + +:Authors: + `Henrik Hallberg `_, + `Olivier Ramonat `_ + +.. contents:: + :backlinks: none + +.. sectnum:: + +statnot is a `notification-daemon +`__ +replacement for lightweight window managers like `dwm +`__ and +`wmii `__. It receives and displays +notifications from the widely used `Desktop +Notifications `__ +specification. + +- `source repository `__ + +Distribution specific links: + +- `archlinux AUR + package `__ + +Background +---------- + +In some lightweight window managers, the text in the status bar is fed +from an external process. For example dwm (version 5.4 and above) reads +the status message from the X root window name, set by +``xsetroot -name ``. A user typically enters a loop like below in +.xinitrc to keep the status bar updating. + +:: + + while true + do + xsetroot -name "$(date +"%F %R")" + sleep 30s # update every thirty seconds + done & + +This solution works well for status messages that are managed from a +single point, for example when printing the same information every time. +statnot lets you combine regular status messages with Desktop +Notifications in a straightforward way. + +Desktop Notifications +~~~~~~~~~~~~~~~~~~~~~ + +If you have used a “regular” window manager like KDE or Gnome, you have +probably come across notifications. The are typically small windows with +text messages and sometimes an icon that shows for a couple of seconds +before they fade out. They are for example used to let the user know +that a new instant message has arrived or that the battery is running +low. Desktop Notifications is a specification created for +freedesktop.org that many applications use. For example Pidgin and +Evolution can be configured to notify for new messages using Desktop +Notifications. + +Installation +------------ + +*Note: if you install statnot through a package manager, some of these +steps have been taken care for you. You probably need to edit the +.xinitrc yourself, see below.* + +To install statnot, first install the required dependencies: + +- `python 2.5+ `__ +- `dbus-python `__ +- `pygtk `__ - (not for GUI support, but the + dbus-python library requires it) + +Next, adjust the target directories in the ``config.mk`` file to fit +your setup. + +To install, run as root: + +:: + + # make install + +Finally, statnot needs to start with the window manager. You can for +example add the following to .xinitrc: + +:: + + killall notification-daemon &> /dev/null + statnot & + +Note that the statnot needs to be the only notification tool running. +The example above makes sure that ``notification-daemon`` is not +running. + +Configuration +------------- + +The major, likely only, part you want to configure in statnot is what +the status message should look like. During installation, a file called +``.statusline.sh`` is created in ``$HOME/``. This gets called with +regular intervals to retrieve the text that should be printed on the +status line. statnot reads STDOUT, so a simple ``echo `` is a good +way to return the text. + +During normal status updates, .statusline.sh is called without +parameters. Here, you typically fetch and ``echo`` information about the +computers performance, battery level or current time. + +Any pending notification is passed as the first argument to +.statusline.sh. This way you can include additional information to the +actual notification. + +Below is an example of .statusline.sh. It prints something like +``[load 0.12 0.10 0.7] 11:42`` in the status bar. When there is a +pending notification, it prints ``NOTIFICATION: ``. + +:: + + if [ $# -eq 0 ]; then + loadavg="`cat /proc/loadavg | awk '{print $1, $2, $3}'`"; + echo "[load ${loadavg}] `date +'%R'`"; + else + echo "NOTIFICATION: $1"; + fi + +For more advanced configuration, a configuration file can be passed to +statnot on the command line, which overrides the default settings. This +configuration file must be written in valid python, which will be read +if the filename is given on the command line. You do only need to set +the variables you want to change, and can leave the rest out. + +Below is an example of a configuration which sets the defaults. + +:: + + # Default time a notification is show, unless specified in notification + DEFAULT_NOTIFY_TIMEOUT = 3000 # milliseconds + + # Maximum time a notification is allowed to show + MAX_NOTIFY_TIMEOUT = 5000 # milliseconds + + # Maximum number of characters in a notification. + NOTIFICATION_MAX_LENGTH = 100 # number of characters + + # Time between regular status updates + STATUS_UPDATE_INTERVAL = 2.0 # seconds + + # Command to fetch status text from. We read from stdout. + # Each argument must be an element in the array + # os must be imported to use os.getenv + import os + STATUS_COMMAND = ['/bin/sh', '%s/.statusline.sh' % os.getenv('HOME')] + + # Always show text from STATUS_COMMAND? If false, only show notifications + USE_STATUSTEXT=True + + # Put incoming notifications in a queue, so each one is shown. + # If false, the most recent notification is shown directly. + QUEUE_NOTIFICATIONS=True + + # update_text(text) is called when the status text should be updated + # If there is a pending notification to be formatted, it is appended as + # the final argument to the STATUS_COMMAND, e.g. as $1 in default shellscript + + # dwm statusbar update + import subprocess + def update_text(text): + # Get first line + first_line = text.splitline()[:-1] + subprocess.call(["xsetroot", "-name", first_line]) + +Possible errors +--------------- + +If no status message shows, verify that statnot is running. Also make +sure your $HOME/.statusline.sh works and prints properly. + +If notifications are not shown, make sure that no other +notification-daemon is running. ``killall notification-daemon`` is a +good command to try. Restart statnot if there was another daemon +running. Also make sure that .statusline.sh takes care of and prints the +$1 parameter (see section Configuration). + +Supported software +------------------ + +More and more applications use Desktop Notifications. Use +`Google `__ to find solutions for your +applications. ``libnotify`` is a good term to search, since it is a +common library used by many applications. + +You can also send your own notifications to statnot. This is easily done +with the ``notify-send`` command. For example, +``notify-send "Hello World"`` will print ``Hello World`` in the status +bar according to your speficiation. This is useful to notify that a long +running task like a download or software build has finished. + +notify-send can also be used for other, more direct messages. For +exampe, I call a script called ``dwm-volume`` when my volume media +buttons on the keyboard are pressed. This script adjusts the volume and +sends a notification containing e.g. ``vol [52%] [on]``. + +:: + + #!/bin/sh + if [ $# -eq 1 ]; then + amixer -q set Master $1 + fi + notify-send -t 0 "`amixer get Master | awk 'NR==5 {print "vol " $4, $6}'`" + +As you can see, I use the option ``-t 0`` to notify-send, i.e. I request +that the notification should show for zero milliseconds. For statnot, +this means that the message should show for a regular status tick, by +default two seconds, but if other notifications arrive, like a second +press on the volume button, it goes away. This setup allows my audio +volume to show only when I change it, while it updates instantly when I +press the media buttons. Note that this option becomes useless if +QUEUE_NOTIFICATIONS is set to False. + +Final notes +----------- + +I’m sure there are other ways to use statnot. For example, one can +create an update_text() that sends notifications as e-mail or instant +messages, or that stores them to a log file. If you create any cool +applications with statnot, I’d be happy to hear about them. + +If you are interested in more examples, my `dotfiles, including +.statusline.sh `__ and +`dwm configuration `__ are +available on `github `__. + +Released under the GPL. Please report any bugs or feature requests by +email. Also, please drop me a line to let me know you like and use this +software. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..32ca5db --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = statnot +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..5c27407 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/stable/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = u'statnot' +copyright = u'2018, Henrik Hallberg, Olivier Ramonat' +author = u'Henrik Hallberg, Olivier Ramonat' + +# The short X.Y version +version = u'' +# The full version, including alpha/beta/rc tags +release = u'0.0.4' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'statnotdoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'statnot.tex', u'statnot Documentation', + u'Henrik Hallberg, Olivier Ramonat', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'statnot', u'statnot Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'statnot', u'statnot Documentation', + author, 'statnot', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..98361dc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,20 @@ +.. statnot documentation master file, created by + sphinx-quickstart on Tue Feb 20 13:37:40 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to statnot's documentation! +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 0000000..d3460c4 --- /dev/null +++ b/docs/modules.rst @@ -0,0 +1,7 @@ +statnot +======= + +.. toctree:: + :maxdepth: 4 + + statnot diff --git a/docs/statnot.rst b/docs/statnot.rst new file mode 100644 index 0000000..a3049c6 --- /dev/null +++ b/docs/statnot.rst @@ -0,0 +1,22 @@ +statnot package +=============== + +Submodules +---------- + +statnot.statnot module +---------------------- + +.. automodule:: statnot.statnot + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: statnot + :members: + :undoc-members: + :show-inheritance: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..63235eb --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +addopts = --doctest-modules +testpaths = statnot tests/ +markers = + integration_test: mark a test as an integration test + acceptation_test: mark a test as an acceptation test diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..555438c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +dbus-python diff --git a/.statusline.sh b/scripts/.statusline.sh similarity index 100% rename from .statusline.sh rename to scripts/.statusline.sh diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..275b168 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,16 @@ +[aliases] +test = pytest + +[tool:pytest] +addopts = -l --doctest-modules +norecursedirs = .git doc build dist *.egg-info .mypy_cache .ropeproject .tox +testpaths = tests + +[flake8] +exclude = .git, __pycache__, docs/source/conf.py, old, build, dist, .tox, .mypy_cache +show-source = 1 +statistics = 1 +ignore = E501, D400 + +[metadata] +description-file = README.rst diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..2f20f0c --- /dev/null +++ b/setup.py @@ -0,0 +1,79 @@ +#! /usr/bin/env python + +import os +import shutil +import subprocess + +from setuptools import find_packages, setup + +DOCS_REQUIRE = ['Sphinx>=1.6.6'] + +LINTERS_REQUIRE = [ + 'flake8>=3.5.0', + 'pylint>=1.8.1' +] + +TESTS_REQUIRE = [ + 'pytest-runner>=3.0', + 'pytest>=3.3.2', + 'coverage>=4.4.2' +] + + +def get_long_description(file_name): + """Gets the long description from the specified file's name. + + :param file_name: The file's name + :type file_name: str + :return: The content of the file + :rtype: str + """ + return open(os.path.join(os.path.dirname(__file__), file_name)).read() + + +if __name__ == '__main__': + setup( + name='statnot', + version='0.0.4', + description='Status/notification system for lightweight window managers.', + author='Henrik Hallberg', + author_email='halhen@k2h.se', + url='https://github.com/halhen/statnot', + license='GPL 2.0 License', + zip_safe=False, + platforms='linux', + python_requires='>=2.7', + packages=find_packages( + exclude=[ + 'tests' + ] + ), + entry_points={ + 'console_scripts': [ + 'statnot = statnot.statnot:main' + ] + }, + data_files=[], + include_package_data=True, + long_description=get_long_description('README.rst'), + test_suite='tests', + scripts=[], + install_requires=[ + 'dbus-python' + ], + extras_require={ + 'dev': DOCS_REQUIRE + LINTERS_REQUIRE + TESTS_REQUIRE, + 'docs': DOCS_REQUIRE, + 'tests': TESTS_REQUIRE, + 'linters': LINTERS_REQUIRE + }, + classifiers=[ + 'Development Status :: 3 - Pre-Alpha', + 'Operating System :: Linux', + 'Programming Language :: Python :: 2' + ], + keywords=[ + 'linux', + 'notifications' + ], + ) diff --git a/statnot b/statnot deleted file mode 100755 index 7046e38..0000000 --- a/statnot +++ /dev/null @@ -1,319 +0,0 @@ -#!/usr/bin/env python - -# -# statnot - Status and Notifications -# -# Lightweight notification-(to-become)-deamon intended to be used -# with lightweight WMs, like dwm. -# Receives Desktop Notifications (including libnotify / notify-send) -# See: http://www.galago-project.org/specs/notification/0.9/index.html -# -# Note: VERY early prototype, to get feedback. -# -# Copyright (c) 2009-2011 by the authors -# http://code.k2h.se -# Please report bugs or feature requests by e-mail. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# - -import dbus -import dbus.service -import dbus.mainloop.glib -import gobject -import os -import subprocess -import sys -import thread -import time -from htmlentitydefs import name2codepoint as n2cp -import re - -# ===== CONFIGURATION DEFAULTS ===== -# -# See helpstring below for what each setting does - -DEFAULT_NOTIFY_TIMEOUT = 3000 # milliseconds -MAX_NOTIFY_TIMEOUT = 5000 # milliseconds -NOTIFICATION_MAX_LENGTH = 100 # number of characters -STATUS_UPDATE_INTERVAL = 2.0 # seconds -STATUS_COMMAND = ["/bin/sh", "%s/.statusline.sh" % os.getenv("HOME")] -USE_STATUSTEXT=True -QUEUE_NOTIFICATIONS=True - -# dwm -def update_text(text): - # Get first line - first_line = text.splitlines()[0] if text else '' - subprocess.call(["xsetroot", "-name", first_line]) - -# ===== CONFIGURATION END ===== - -def _getconfigvalue(configmodule, name, default): - if hasattr(configmodule, name): - return getattr(configmodule, name) - return default - -def readconfig(filename): - import imp - try: - config = imp.load_source("config", filename) - except Exception as e: - print "Error: failed to read config file %s" % filename - print e - sys.exit(2) - - for setting in ("DEFAULT_NOTIFY_TIMEOUT", "MAX_NOTIFY_TIMEOUT", "NOTIFICATION_MAX_LENGTH", "STATUS_UPDATE_INTERVAL", - "STATUS_COMMAND", "USE_STATUSTEXT", "QUEUE_NOTIFICATIONS", "update_text"): - if hasattr(config, setting): - globals()[setting] = getattr(config, setting) - -def strip_tags(value): - "Return the given HTML with all tags stripped." - return re.sub(r'<[^>]*?>', '', value) - -# from http://snipplr.com/view/19472/decode-html-entities/ -# also on http://snippets.dzone.com/posts/show/4569 -def substitute_entity(match): - ent = match.group(3) - if match.group(1) == "#": - if match.group(2) == '': - return unichr(int(ent)) - elif match.group(2) == 'x': - return unichr(int('0x'+ent, 16)) - else: - cp = n2cp.get(ent) - if cp: - return unichr(cp) - else: - return match.group() - -def decode_htmlentities(string): - entity_re = re.compile(r'&(#?)(x?)(\w+);') - return entity_re.subn(substitute_entity, string)[0] - -# List of not shown notifications. -# Array of arrays: [id, text, timeout in s] -# 0th element is being displayed right now, and may change -# Replacements of notification happens att add -# message_thread only checks first element for changes -notification_queue = [] -notification_queue_lock = thread.allocate_lock() - -def add_notification(notif): - with notification_queue_lock: - for index, n in enumerate(notification_queue): - if n[0] == notif[0]: # same id, replace instead of queue - n[1:] = notif[1:] - return - - notification_queue.append(notif) - -def next_notification(pop = False): - # No need to be thread safe here. Also most common scenario - if not notification_queue: - return None - - with notification_queue_lock: - if QUEUE_NOTIFICATIONS: - # If there are several pending messages, discard the first 0-timeouts - while len(notification_queue) > 1 and notification_queue[0][2] == 0: - notification_queue.pop(0) - else: - while len(notification_queue) > 1: - notification_queue.pop(0) - - if pop: - return notification_queue.pop(0) - else: - return notification_queue[0] - -def get_statustext(notification = ''): - output = '' - try: - if not notification: - command = STATUS_COMMAND - else: - command = STATUS_COMMAND + [notification] - - p = subprocess.Popen(command, stdout=subprocess.PIPE) - - output = p.stdout.read() - except: - sys.stderr.write("%s: could not read status message (%s)\n" - % (sys.argv[0], ' '.join(STATUS_COMMAND))) - - # Error - STATUS_COMMAND didn't exist or delivered empty result - # Fallback to notification only - if not output: - output = notification - - return output - -def message_thread(dummy): - last_status_update = 0 - last_notification_update = 0 - current_notification_text = '' - - while 1: - notif = next_notification() - current_time = time.time() - update_status = False - - if notif: - if notif[1] != current_notification_text: - update_status = True - - elif current_time > last_notification_update + notif[2]: - # If requested timeout is zero, notification shows until - # a new notification arrives or a regular status mesasge - # cleans it - # This way is a bit risky, but works. Keep an eye on this - # when changing code - if notif[2] != 0: - update_status = True - - # Pop expired notification - next_notification(True) - notif = next_notification() - - if update_status == True: - last_notification_update = current_time - - if current_time > last_status_update + STATUS_UPDATE_INTERVAL: - update_status = True - - if update_status: - if notif: - current_notification_text = notif[1] - else: - current_notification_text = '' - - if USE_STATUSTEXT: - update_text(get_statustext(current_notification_text)) - else: - if current_notification_text != '': - update_text(current_notification_text) - - last_status_update = current_time - - time.sleep(0.1) - -class NotificationFetcher(dbus.service.Object): - _id = 0 - - @dbus.service.method("org.freedesktop.Notifications", - in_signature='susssasa{ss}i', - out_signature='u') - def Notify(self, app_name, notification_id, app_icon, - summary, body, actions, hints, expire_timeout): - if (expire_timeout < 0) or (expire_timeout > MAX_NOTIFY_TIMEOUT): - expire_timeout = DEFAULT_NOTIFY_TIMEOUT - - if not notification_id: - self._id += 1 - notification_id = self._id - - text = ("%s %s" % (summary, body)).strip() - add_notification( [notification_id, - text[:NOTIFICATION_MAX_LENGTH], - int(expire_timeout) / 1000.0] ) - return notification_id - - @dbus.service.method("org.freedesktop.Notifications", in_signature='', out_signature='as') - def GetCapabilities(self): - return ("body") - - @dbus.service.signal('org.freedesktop.Notifications', signature='uu') - def NotificationClosed(self, id_in, reason_in): - pass - - @dbus.service.method("org.freedesktop.Notifications", in_signature='u', out_signature='') - def CloseNotification(self, id): - pass - - @dbus.service.method("org.freedesktop.Notifications", in_signature='', out_signature='ssss') - def GetServerInformation(self): - return ("statnot", "http://code.k2h.se", "0.0.2", "1") - -if __name__ == '__main__': - for curarg in sys.argv[1:]: - if curarg in ('-v', '--version'): - print "%s CURVERSION" % sys.argv[0] - sys.exit(1) - elif curarg in ('-h', '--help'): - print " Usage: %s [-h] [--help] [-v] [--version] [configuration file]" % sys.argv[0] - print " -h, --help: Print this help and exit" - print " -v, --version: Print version and exit" - print "" - print " Configuration:" - print " A file can be read to set the configuration." - print " This configuration file must be written in valid python," - print " which will be read if the filename is given on the command line." - print " You do only need to set the variables you want to change, and can" - print " leave the rest out." - print "" - print " Below is an example of a configuration which sets the defaults." - print "" - print " # Default time a notification is show, unless specified in notification" - print " DEFAULT_NOTIFY_TIMEOUT = 3000 # milliseconds" - print " " - print " # Maximum time a notification is allowed to show" - print " MAX_NOTIFY_TIMEOUT = 5000 # milliseconds" - print " " - print " # Maximum number of characters in a notification. " - print " NOTIFICATION_MAX_LENGTH = 100 # number of characters" - print " " - print " # Time between regular status updates" - print " STATUS_UPDATE_INTERVAL = 2.0 # seconds" - print " " - print " # Command to fetch status text from. We read from stdout." - print " # Each argument must be an element in the array" - print " # os must be imported to use os.getenv" - print " import os" - print " STATUS_COMMAND = ['/bin/sh', '%s/.statusline.sh' % os.getenv('HOME')] " - print "" - print " # Always show text from STATUS_COMMAND? If false, only show notifications" - print " USE_STATUSTEXT=True" - print " " - print " # Put incoming notifications in a queue, so each one is shown." - print " # If false, the most recent notification is shown directly." - print " QUEUE_NOTIFICATIONS=True" - print " " - print " # update_text(text) is called when the status text should be updated" - print " # If there is a pending notification to be formatted, it is appended as" - print " # the final argument to the STATUS_COMMAND, e.g. as $1 in default shellscript" - print "" - print " # dwm statusbar update" - print " import subprocess" - print " def update_text(text):" - print " subprocess.call(['xsetroot', '-name', text])" - sys.exit(1) - else: - readconfig(curarg) - - dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - session_bus = dbus.SessionBus() - name = dbus.service.BusName("org.freedesktop.Notifications", session_bus) - nf = NotificationFetcher(session_bus, '/org/freedesktop/Notifications') - - # We must use contexts and iterations to run threads - # http://www.jejik.com/articles/2007/01/python-gstreamer_threading_and_the_main_loop/ - gobject.threads_init() - context = gobject.MainLoop().get_context() - thread.start_new_thread(message_thread, (None,)) - - while 1: - context.iteration(True) - diff --git a/statnot/__init__.py b/statnot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/statnot/statnot.py b/statnot/statnot.py new file mode 100644 index 0000000..7d1fc28 --- /dev/null +++ b/statnot/statnot.py @@ -0,0 +1,372 @@ +""" +statnot - Status and Notifications + +Lightweight notification-(to-become)-deamon intended to be used +with lightweight WMs, like dwm. +Receives Desktop Notifications (including libnotify / notify-send) + +:copyright: (c) 2009-2011 by the authors + +.. seealso:: + + http://www.galago-project.org/specs/notification/0.9/index.html + +.. note:: + + VERY early prototype, to get feedback. + +http://code.k2h.se +Please report bugs or feature requests by e-mail. + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import dbus +import dbus.service +import dbus.mainloop.glib +import gobject + +import os +import re +import subprocess +import sys +import textwrap +import thread +import time + +from argparse import ( + ArgumentParser, + RawDescriptionHelpFormatter +) +from htmlentitydefs import name2codepoint as n2cp + +# ===== CONFIGURATION DEFAULTS ===== +# +# See helpstring below for what each setting does + +DEFAULT_NOTIFY_TIMEOUT = 3000 # milliseconds +MAX_NOTIFY_TIMEOUT = 5000 # milliseconds +NOTIFICATION_MAX_LENGTH = 100 # number of characters +STATUS_UPDATE_INTERVAL = 2.0 # seconds +STATUS_COMMAND = ['/bin/sh', '%s/.statusline.sh' % os.getenv('HOME')] +USE_STATUSTEXT = True +QUEUE_NOTIFICATIONS = True +SETTINGS = ( + 'DEFAULT_NOTIFY_TIMEOUT', + 'MAX_NOTIFY_TIMEOUT', + 'NOTIFICATION_MAX_LENGTH', + 'STATUS_UPDATE_INTERVAL', + 'STATUS_COMMAND', + 'USE_STATUSTEXT', + 'QUEUE_NOTIFICATIONS', + 'update_text' +) + +VERSION = '0.0.4' + + +# dwm +def update_text(text): + # Get first line + first_line = text.splitlines()[0] if text else '' + subprocess.call(['xsetroot', '-name', first_line]) + + +# ===== CONFIGURATION END ===== + +def _getconfigvalue(configmodule, name, default): + if hasattr(configmodule, name): + return getattr(configmodule, name) + return default + + +def readconfig(filename): + import imp + try: + config = imp.load_source('config', filename) + except Exception as e: + print 'Error: failed to read config file %s' % filename + print e + sys.exit(2) + + for setting in SETTINGS: + if hasattr(config, setting): + globals()[setting] = getattr(config, setting) + + +def strip_tags(value): + """Return the given HTML with all tags stripped.""" + return re.sub(r'<[^>]*?>', '', value) + + +# from http://snipplr.com/view/19472/decode-html-entities/ +# also on http://snippets.dzone.com/posts/show/4569 +def substitute_entity(match): + ent = match.group(3) + if match.group(1) == '#': + if match.group(2) == '': + return unichr(int(ent)) + elif match.group(2) == 'x': + return unichr(int('0x' + ent, 16)) + else: + cp = n2cp.get(ent) + if cp: + return unichr(cp) + else: + return match.group() + + +def decode_htmlentities(string): + entity_re = re.compile(r'&(#?)(x?)(\w+);') + return entity_re.subn(substitute_entity, string)[0] + + +# List of not shown notifications. +# Array of arrays: [id, text, timeout in s] +# 0th element is being displayed right now, and may change +# Replacements of notification happens att add +# message_thread only checks first element for changes +notification_queue = [] +notification_queue_lock = thread.allocate_lock() + + +def add_notification(notif): + with notification_queue_lock: + for index, n in enumerate(notification_queue): + if n[0] == notif[0]: # same id, replace instead of queue + n[1:] = notif[1:] + return + + notification_queue.append(notif) + + +def next_notification(pop=False): + # No need to be thread safe here. Also most common scenario + if not notification_queue: + return None + + with notification_queue_lock: + if QUEUE_NOTIFICATIONS: + # If there are several pending messages, discard the first 0-timeouts + while len(notification_queue) > 1 and notification_queue[0][2] == 0: + notification_queue.pop(0) + else: + while len(notification_queue) > 1: + notification_queue.pop(0) + + if pop: + return notification_queue.pop(0) + else: + return notification_queue[0] + + +def get_statustext(notification=''): + output = '' + try: + if not notification: + command = STATUS_COMMAND + else: + command = STATUS_COMMAND + [notification] + + p = subprocess.Popen(command, stdout=subprocess.PIPE) + + output = p.stdout.read() + except: + sys.stderr.write('%s: could not read status message (%s)\n' + % (sys.argv[0], ' '.join(STATUS_COMMAND))) + + # Error - STATUS_COMMAND didn't exist or delivered empty result + # Fallback to notification only + if not output: + output = notification + + return output + + +def message_thread(dummy): + last_status_update = 0 + last_notification_update = 0 + current_notification_text = '' + + while True: + notif = next_notification() + current_time = time.time() + update_status = False + + if notif: + if notif[1] != current_notification_text: + update_status = True + + elif current_time > last_notification_update + notif[2]: + # If requested timeout is zero, notification shows until + # a new notification arrives or a regular status mesasge + # cleans it + # This way is a bit risky, but works. Keep an eye on this + # when changing code + if notif[2] != 0: + update_status = True + + # Pop expired notification + next_notification(True) + notif = next_notification() + + if update_status: + last_notification_update = current_time + + if current_time > last_status_update + STATUS_UPDATE_INTERVAL: + update_status = True + + if update_status: + if notif: + current_notification_text = notif[1] + else: + current_notification_text = '' + + if USE_STATUSTEXT: + update_text(get_statustext(current_notification_text)) + else: + if current_notification_text != '': + update_text(current_notification_text) + + last_status_update = current_time + + time.sleep(0.1) + + +class NotificationFetcher(dbus.service.Object): + _id = 0 + + @dbus.service.method('org.freedesktop.Notifications', + in_signature='susssasa{ss}i', + out_signature='u') + def Notify(self, app_name, notification_id, app_icon, + summary, body, actions, hints, expire_timeout): + if (expire_timeout < 0) or (expire_timeout > MAX_NOTIFY_TIMEOUT): + expire_timeout = DEFAULT_NOTIFY_TIMEOUT + + if not notification_id: + self._id += 1 + notification_id = self._id + + text = ('%s %s' % (summary, body)).strip() + add_notification([notification_id, + text[:NOTIFICATION_MAX_LENGTH], + int(expire_timeout) / 1000.0]) + return notification_id + + @dbus.service.method('org.freedesktop.Notifications', in_signature='', out_signature='as') + def GetCapabilities(self): + return "body" + + @dbus.service.signal('org.freedesktop.Notifications', signature='uu') + def NotificationClosed(self, id_in, reason_in): + pass + + @dbus.service.method('org.freedesktop.Notifications', in_signature='u', out_signature='') + def CloseNotification(self, id): + pass + + @dbus.service.method('org.freedesktop.Notifications', in_signature='', out_signature='ssss') + def GetServerInformation(self): + return 'statnot', 'http://code.k2h.se', '0.0.2', '1' + + +def _parse_arguments(): + parser = ArgumentParser( + formatter_class=RawDescriptionHelpFormatter, + prog='statnot', + description=('Lightweight notification-(to-become)-deamon intended to be used ' + 'with lightweight WMs, like dwm.'), + epilog=( + textwrap.dedent( + ''' + configuration: + A file can be read to set the configuration. + This configuration file must be written in valid python, + which will be read if the filename is given on the command line. + You do only need to set the variables you want to change, and can + leave the rest out. + + Below is an example of a configuration which sets the defaults. + + # Default time a notification is show, unless specified in notification + DEFAULT_NOTIFY_TIMEOUT = 3000 # milliseconds + + # Maximum time a notification is allowed to show + MAX_NOTIFY_TIMEOUT = 5000 # milliseconds + + # Maximum number of characters in a notification. + NOTIFICATION_MAX_LENGTH = 100 # number of characters + + # Time between regular status updates + STATUS_UPDATE_INTERVAL = 2.0 # seconds + + # Command to fetch status text from. We read from stdout. + # Each argument must be an element in the array + # os must be imported to use os.getenv + import os + STATUS_COMMAND = ['/bin/sh', '%s/.statusline.sh' % os.getenv('HOME')] + # Always show text from STATUS_COMMAND? If false, only show notifications + USE_STATUSTEXT=True + + # Put incoming notifications in a queue, so each one is shown. + # If false, the most recent notification is shown directly. + QUEUE_NOTIFICATIONS=True + + # update_text(text) is called when the status text should be updated + # If there is a pending notification to be formatted, it is appended as + # the final argument to the STATUS_COMMAND, e.g. as $1 in default shellscript + + # dwm statusbar update + import subprocess + def update_text(text): + subprocess.call(['xsetroot', '-name', text])''' + ) + ) + ) + parser.add_argument( + '-v', + '--version', + action='version', + version='%(prog)s {version}'.format(version=VERSION) + ) + parser.add_argument( + 'configuration_file', + type=str, + metavar='FILENAME', + help='name of the configuration file (see the end of this prompt for an example)' + ) + parser.set_defaults(function=readconfig) + + return parser.parse_args() + + +def main(): + """Main entry point of the application.""" + arguments = _parse_arguments() + arguments.function(arguments.configuration_file) + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + session_bus = dbus.SessionBus() + name = dbus.service.BusName('org.freedesktop.Notifications', session_bus) + nf = NotificationFetcher(session_bus, '/org/freedesktop/Notifications') + + # We must use contexts and iterations to run threads + # http://www.jejik.com/articles/2007/01/python-gstreamer_threading_and_the_main_loop/ + gobject.threads_init() + context = gobject.MainLoop().get_context() + thread.start_new_thread(message_thread, (None,)) + + while True: + context.iteration(True) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3a75067 --- /dev/null +++ b/tox.ini @@ -0,0 +1,32 @@ +[tox] +envlist = + py{27} + coverage-report + flake + pylint + +[testenv] +passenv = LANG +usedevelop = True +deps = + pytest>=3 + coverage + +commands = + coverage run -p -m pytest tests + +[testenv:coverage-report] +deps = coverage +skip_install = true +commands = + coverage combine + coverage report + coverage html + +[testenv:flake] +deps = flake8 +commands = flake8 statnot + +[testenv:pylint] +deps = pylint +commands = pylint statnot