1
0
mirror of https://github.com/HorlogeSkynet/archey4 synced 2025-07-09 00:00:12 +02:00

Compare commits

...

29 Commits

Author SHA1 Message Date
a575f3b80a [SCREENSHOT] Keeps quiet when everything went well 2020-05-31 22:14:05 +02:00
b16e8c4186 [SCREENSHOT] More (rough) modularity in line-cleaning 2020-05-31 22:11:03 +02:00
ccdafef9e7 [SCREENSHOT] Add delay before screenshot is taken.
Rationale: Before, sometimes the screenshot would be taken before
Archey's output was visible in the terminal. This delay ensures that
the terminal has had the chance to display the output before we take any
screenshot.

Additionally, this commit adds a success message detailing the
screenshot program used.
2020-05-31 13:07:35 +01:00
28cd846173 [SCREENSHOT] Update file-naming to more closely resemble ISO 8601
Also does not stop trying to take a screenshot on one program's failure,
and keeps error messages from printing until a screenshot has been taken.
2020-05-31 03:58:02 +01:00
b8f1f799c4 [SCREENSHOT] Let's accept directory from CLI and do our best to honor it 2020-05-30 10:31:04 +02:00
44f0299379 [DOC] Advises user to show help message as there are now many CLI params 2020-05-29 15:46:51 +02:00
bbb39a1d7e [FEATURE] Implements a -s option to make Archey take a screenshot 2020-05-29 15:46:47 +02:00
6fe7f45f71 [OUTPUT/DISTRO] Moves distro interaction to Distributions module
+ Improves documentation and some code styles
+ Simplifies `Output` testing
+ Fixes test on systems running an unknown distribution
2020-05-28 15:00:25 +02:00
4154ac03cd [CONFIG] Improves options consistency (ordering & internal definition) 2020-05-28 12:33:50 +02:00
eba811e1ba [LAN_IP/MODEL/PROCESSES] [TEST] Cleaning + Silences STDERR print-ing 2020-05-28 12:33:50 +02:00
a0ef6e3e32 [CONFIG] Implements a -c CLI option to specify a configuration path 2020-05-28 12:33:46 +02:00
f15d7eea90 [TERMINAL] [TEST] Improves/Simplifies cases according to new behavior 2020-05-27 23:33:54 +02:00
2b10c7dc00 [CONFIG] Cleans up the whole Configuration singleton definition 2020-05-27 23:20:51 +02:00
f74dda9241 [FEATURE] Makes entries being loaded in parallel (#74)
This patch is mainly inspired from the work of @ingrinder (see `2bbc2dae`).
You may notice an execution up to twice as fast.

This behavior could be disabled with the new `parallel_loading` configuration option.

Co-authored-by: Michael Bromilow <12384431+ingrinder@users.noreply.github.com>
2020-05-26 07:02:39 +00:00
c6aa6ced96 [DOC] Updates "Notes to users" README section 2020-05-25 11:33:07 +02:00
aef1cd0bc8 [PROCESSES] Prefers a much simpler & cross-platforms ps call 2020-05-25 11:33:04 +02:00
a1d0f27c5c [RAM] Makes entry readable and compatible with BSD systems (see #69) 2020-05-25 10:54:05 +02:00
a089b80d87 [MODEL] Makes entry compatible with BSD systems (see #69) 2020-05-25 10:17:19 +02:00
aab40469b8 [CPU] Optimizes entry and makes it compatible with BSD systems (see #69) 2020-05-25 10:13:36 +02:00
05dfeab716 [PACKAGING] Improves paths and arguments consistency in build.sh 2020-05-24 17:30:19 +02:00
c7feeb6921 [PACKAGING] Deletes untracked byte-code files on package removal
... and documents the reason why we are not shipping them in the first place.
2020-05-24 10:01:19 +02:00
3ae7b20e45 [META] Makes Setuptools & PIP fully-aware of Python version requirements 2020-05-24 09:26:07 +02:00
6fed07786b [MODEL] [TEST] Simplifies open mock side_effect usages 2020-05-23 22:55:22 +02:00
794e759020 [TEMPERATURE] [TEST] Simplifies _convert_to_fahrenheit logic 2020-05-23 22:44:23 +02:00
b680ca3705 [META] Let's "officially" support Python 3.9 2020-05-22 11:22:54 +02:00
a919c53116 [META] [DOC] Simplifies, removes & re-organizes README's badges 2020-05-22 11:13:09 +02:00
754e485ff1 [DOC] Fixes two typos in README's ip_settings section 2020-05-22 11:13:09 +02:00
c108f75579 [TERMINAL] [BREAKING] Enable colors_palette.use_unicode by default
Rationale: As of 2020, most of default systems locale support Unicode.
           Users with old (or very specific) machines WILL still be able to disable it from configuration.
2020-05-22 11:13:09 +02:00
5c8954e0e5 [META] Let's start working on v4.8.0 2020-05-22 11:13:03 +02:00
30 changed files with 837 additions and 590 deletions

@ -5,6 +5,7 @@ python:
- "3.6"
- "3.7"
- "3.8"
- "3.9-dev"
- "pypy3"
install:

@ -3,26 +3,21 @@
> Archey is a simple system information tool written in Python
<p align="center">
<!-- TRAVIS CI -->
<a href="https://travis-ci.org/HorlogeSkynet/archey4"><img src="https://img.shields.io/travis/HorlogeSkynet/archey4/master.svg?style=for-the-badge"></a>
<br />
<!-- GITHUB -->
<!-- GITHUB & TRAVIS CI -->
<a href="https://github.com/HorlogeSkynet/archey4/releases/latest"><img src="https://img.shields.io/github/release/HorlogeSkynet/archey4.svg?style=for-the-badge"></a>
<a href="https://travis-ci.org/HorlogeSkynet/archey4"><img src="https://img.shields.io/travis/HorlogeSkynet/archey4/master.svg?style=for-the-badge"></a>
<a href="https://github.com/HorlogeSkynet/archey4/commits/master"><img src="https://img.shields.io/github/last-commit/HorlogeSkynet/archey4.svg?style=for-the-badge"></a>
<br />
<a href="https://github.com/HorlogeSkynet/archey4/issues"><img src="https://img.shields.io/github/issues/HorlogeSkynet/archey4.svg?style=for-the-badge"></a>
<a href="https://github.com/HorlogeSkynet/archey4/pulls"><img src="https://img.shields.io/github/issues-pr/HorlogeSkynet/archey4.svg?style=for-the-badge"></a>
<br />
<!-- AUR -->
<a href="https://aur.archlinux.org/packages/archey4/"><img src="https://img.shields.io/aur/version/archey4.svg?style=for-the-badge"></a>
<a href="https://aur.archlinux.org/packages/archey4/"><img src="https://img.shields.io/aur/votes/archey4.svg?style=for-the-badge"></a>
<a href="https://aur.archlinux.org/packages/archey4/"><img src="https://img.shields.io/aur/license/archey4.svg?style=for-the-badge"></a>
<a href="https://aur.archlinux.org/packages/archey4/"><img src="https://img.shields.io/aur/votes/archey4.svg?style=for-the-badge"></a>
<a href="https://aur.archlinux.org/packages/archey4/"><img src="https://img.shields.io/aur/last-modified/archey4.svg?style=for-the-badge"></a>
<br />
<!-- PYPI -->
<a href="https://pypi.org/project/archey4/"><img src="https://img.shields.io/pypi/v/archey4.svg?style=for-the-badge"></a>
<a href="https://pypi.org/project/archey4/"><img src="https://img.shields.io/pypi/pyversions/archey4.svg?style=for-the-badge"></a>
<a href="https://pypi.org/project/archey4/"><img src="https://img.shields.io/pypi/dm/archey4?style=for-the-badge"></a>
<a href="https://pypi.org/project/archey4/"><img src="https://img.shields.io/pypi/wheel/archey4.svg?style=for-the-badge"></a>
</p>
<p align="center">
@ -146,13 +141,13 @@ sudo mv dist/archey /usr/local/bin/
## Usage
```bash
archey
archey --help
```
or if you only want to try this out (for instance, from source) :
```bash
python3 -m archey
python3 -m archey --help
```
## Configuration (optional)
@ -175,6 +170,8 @@ Below, some further explanations of each option available :
// If set to `false`, configurations defined afterwards won't be loaded.
// Developers running Archey from the original project may keep in there the original `config.json` while having their own external configuration set elsewhere.
"allow_overriding": true,
// Set to `false` to disable multi-threaded loading of entries.
"parallel_loading": true,
// If set to `true`, any execution warning or error would be hidden.
// It may not apply to configuration parsing warnings.
"suppress_warnings": false,
@ -182,9 +179,9 @@ Below, some further explanations of each option available :
// Set to `false` each entry you want to mask.
},
"colors_palette": {
// Set this option to `true` to display a beautiful colors palette.
// `false` by default for backward compatibility with non-Unicode locales.
"use_unicode": false,
// Leave this option set to `true` to display a beautiful colors palette.
// Set it to `false` to allow compatibility with non-Unicode locales.
"use_unicode": true,
// Set this option to `false` to force Archey to use its own colors palettes.
// `true` by default to honor `os-release`'s `ANSI_COLOR` option.
"honor_ansi_color": true
@ -196,9 +193,9 @@ Below, some further explanations of each option available :
// The maximum number of local addresses you want to display.
// `false` --> Unlimited.
"lan_ip_max_count": 2,
// `false` would make Archey displays only IPv4 LAN addresses.
// `false` would make Archey display IPv4 LAN addresses only.
"lan_ip_v6_support": true,
// `false` would make Archey displays only IPv4 WAN addresses.
// `false` would make Archey display IPv4 WAN addresses only.
"wan_ip_v6_support": true
},
"gpu": {
@ -255,13 +252,9 @@ Any improvement would be appreciated.
## Notes to users
* If you run `archey` as root, the script will list the processes running by other users on your system in order to display the **Window Manager** & **Desktop Environment** outputs correctly.
* During the setup procedure, I advised you to copy this script into the `/usr/local/bin/` folder, you may want to check what it does beforehand.
* If you experience any trouble during the installation or usage, please do **[open an issue](https://github.com/HorlogeSkynet/archey4/issues/new)**.
* If you had to adapt the script to make it work on your system, please **[open a pull request](https://github.com/HorlogeSkynet/archey4/pulls)** so as to share your modifications with the rest of the world and participate in this project !
* If you had to tweak this project to make it work on your system, please **[open a pull request](https://github.com/HorlogeSkynet/archey4/pulls)** so as to share your modifications with the rest of the world and participate in this project !
* When looking up your public IP address (**WAN\_IP**), Archey will try at first to run a DNS query for `myip.opendns.com`, against OpenDNS's resolver(s). On error, it would fall back on regular HTTPS request(s) to <https://ident.me> ([server sources](https://github.com/pcarrier/identme)).

@ -1,5 +1,5 @@
.\" Please, before submitting any change, run:
.\" `groff -man -Tascii -z archey4.1`
.\" `groff -man -Tascii -z archey.1`
.TH ARCHEY4 1 "${DATE}" "archey4 ${VERSION}" "Archey4 man page"
@ -31,13 +31,20 @@ Remain \fImaintained\fR, \fIcommunity-driven\fR and
.IP "-h, --help"
show help message and exit
.IP "-v, --version"
show program's version number and exit
.IP "-c, --config-path PATH"
path to a configuration file, or a directory containing a `config.json`
.IP "-j, --json"
output entries data to JSON format, use multiple times to increase
indentation
.IP "-s, --screenshot [FILENAME]"
take a screenshot once execution is done, optionally specify a target
path
.IP "-v, --version"
show program's version number and exit
.P
Archey will regularly run in terminal text output mode if no argument
is passed.

@ -7,13 +7,17 @@ Logos are stored under the `logos` module.
"""
import argparse
import os
from enum import Enum
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from archey._version import __version__
from archey.output import Output
from archey.configuration import Configuration
from archey.processes import Processes
from archey.screenshot import take_screenshot
from archey.entries.user import User as e_User
from archey.entries.hostname import Hostname as e_Hostname
from archey.entries.model import Model as e_Model
@ -60,37 +64,83 @@ class Entries(Enum):
WAN_IP = e_WanIp
def main():
"""Simple entry point"""
def args_parsing():
"""Simple wrapper to `argparse`"""
parser = argparse.ArgumentParser(prog='archey')
parser.add_argument(
'-c', '--config-path',
metavar='PATH',
help='path to a configuration file, or a directory containing a `config.json`'
)
parser.add_argument(
'-j', '--json',
action='count',
help='output entries data to JSON format, use multiple times to increase indentation'
)
parser.add_argument(
'-s', '--screenshot',
metavar='PATH',
nargs='?',
const=False,
help='take a screenshot once execution is done, optionally specify a target path'
)
parser.add_argument(
'-v', '--version',
action='version',
version=__version__
)
args = parser.parse_args()
return parser.parse_args()
def main():
"""Simple entry point"""
args = args_parsing()
# `Processes` is a singleton, let's populate the internal list here.
Processes()
# `Configuration` is a singleton, let's populate the internal object here.
configuration = Configuration()
configuration = Configuration(config_path=args.config_path)
# From configuration, gather the entries user-enabled.
enabled_entries = [
(entry.value, entry.name)
for entry in Entries
if configuration.get('entries', {}).get(entry.name, True)
]
output = Output(
format_to_json=args.json
)
for entry in Entries:
if configuration.get('entries', {}).get(entry.name, True):
output.add_entry(entry.value(name=entry.name))
# We will map this function onto our enabled entries to instantiate them.
def _entry_instantiator(entry_tuple):
return entry_tuple[0](name=entry_tuple[1])
# Let's use a context manager stack to manage conditional use of `TheadPoolExecutor`.
with ExitStack() as cm_stack:
if not configuration.get('parallel_loading'):
mapper = map
else:
# Instantiate a threads pool to load our enabled entries in parallel.
# We use threads (and not processes) since most work done by our entries is IO-bound.
# `max_workers` is manually computed to mimic Python 3.8+ behaviour, but for our needs.
# See <https://github.com/python/cpython/pull/13618>.
executor = cm_stack.enter_context(ThreadPoolExecutor(
max_workers=min(len(enabled_entries) or 1, (os.cpu_count() or 1) + 4)
))
mapper = executor.map
for entry_instance in mapper(_entry_instantiator, enabled_entries):
output.add_entry(entry_instance)
output.output()
# Has the screenshot flag been specified ?
if args.screenshot is not None:
# If so, but still _falsy_, pass `None` as no output file has been specified by the user.
take_screenshot((args.screenshot or None))
if __name__ == '__main__':
main()

@ -1,3 +1,3 @@
"""Simple module storing the current project version"""
__version__ = 'v4.7.2'
__version__ = 'v4.8.0-beta'

@ -1,5 +1,6 @@
{
"allow_overriding": true,
"parallel_loading": true,
"suppress_warnings": false,
"entries": {
"User": true,
@ -22,7 +23,7 @@
"WAN_IP": true
},
"colors_palette": {
"use_unicode": false,
"use_unicode": true,
"honor_ansi_color": true
},
"default_strings": {

@ -10,13 +10,18 @@ from archey.singleton import Singleton
class Configuration(metaclass=Singleton):
"""
The default needed configuration which will be used by Archey is present below.
Values present in the `self.config` dictionary below are needed.
New optional values may be added with `_update_recursive()` method.
Values present in the `self._config` dictionary below are needed.
New optional values may be added with `_update_recursive` method.
If a `config_path` is passed during instantiation, it will be loaded.
"""
def __init__(self):
def __init__(self, config_path=None):
self._config = {
'allow_overriding': True,
'parallel_loading': True,
'suppress_warnings': False,
'colors_palette': {
'use_unicode': False,
'use_unicode': True,
'honor_ansi_color': True
},
'default_strings': {
@ -57,10 +62,14 @@ class Configuration(metaclass=Singleton):
# Let's "save" `STDERR` file descriptor for `suppress_warnings` option
self._stderr = sys.stderr
# Now, let's load each optional configuration file in a "regular" order
self.load_configuration('/etc/archey4/')
self.load_configuration(os.path.expanduser('~/.config/archey4/'))
self.load_configuration(os.path.dirname(os.path.realpath(__file__)))
# If a `config_path` has been specified, (try to) load it directly.
if config_path:
self._load_configuration(config_path)
# If not, load each (optional) configuration file in a "regular" order.
else:
self._load_configuration('/etc/archey4/')
self._load_configuration(os.path.expanduser('~/.config/archey4/'))
self._load_configuration(os.path.dirname(os.path.realpath(__file__)))
def get(self, key, default=None):
"""
@ -68,59 +77,57 @@ class Configuration(metaclass=Singleton):
"""
return self._config.get(key, default)
def load_configuration(self, path):
def _load_configuration(self, path):
"""
A method handling configuration loading from a JSON file.
It will try to load any `config.json` present under `path`.
"""
# If a previous configuration file has denied overriding...
if not self._config.get('allow_overriding', True):
if not self.get('allow_overriding'):
# ... don't load this one.
return
path = os.path.join(path, 'config.json')
# If the specified `path` is a directory, append the file name we are looking for.
if os.path.isdir(path):
path = os.path.join(path, 'config.json')
try:
with open(path) as file:
self._update_recursive(self._config, json.load(file))
# If the user does not want any warning to appear : 2> /dev/null
if self._config.get('suppress_warnings', False):
# One more if statement to avoid multiple `open` calls.
if sys.stderr == self._stderr:
sys.stderr = open(os.devnull, 'w')
else:
# One more if statement to avoid useless assignments and...
# ... for closing previously opened new file descriptor.
if sys.stderr != self._stderr:
sys.stderr.close()
sys.stderr = self._stderr
with open(path) as f_config:
self._update_recursive(self._config, json.load(f_config))
except FileNotFoundError:
pass
return
# For backward compatibility with Python versions prior to 3.5.0
# we use `ValueError` instead of `json.JSONDecodeError`.
except ValueError as error:
print('Warning: {0} ({1})'.format(error, path), file=sys.stderr)
except ValueError as value_error:
print('Warning: {0} ({1})'.format(value_error, path), file=sys.stderr)
return
# If the user does not want any warning to appear : 2> /dev/null
if self.get('suppress_warnings'):
# One more if statement to avoid multiple `open` calls.
if sys.stderr == self._stderr:
sys.stderr = open(os.devnull, 'w')
else:
self._close_and_restore_sys_stderr()
def _update_recursive(self, old_dict, new_dict):
"""
A method for recursively merging dictionaries as...
... `dict.update()` is not able to do this.
Original snippet taken from here :
https://gist.github.com/angstwad/bf22d1822c38a92ec0a9
A method for recursively merging dictionaries as `dict.update()` is not able to do this.
Original snippet taken from here : <https://gist.github.com/angstwad/bf22d1822c38a92ec0a9>
"""
for key, value in new_dict.items():
if key in old_dict and isinstance(old_dict[key], dict) \
and isinstance(value, dict):
if key in old_dict \
and isinstance(old_dict[key], dict) \
and isinstance(value, dict):
self._update_recursive(old_dict[key], value)
else:
old_dict[key] = value
def __del__(self):
def _close_and_restore_sys_stderr(self):
"""If modified, close current and restore `sys.stderr` to its original file descriptor"""
if sys.stderr != self._stderr:
sys.stderr.close()
sys.stderr = self._stderr
def __del__(self):
self._close_and_restore_sys_stderr()

@ -1,6 +1,15 @@
"""Distributions enumeration"""
"""
Distributions enumeration module.
Operating Systems detection logic.
Interface to `os-release` (through `distro` module).
"""
import sys
from enum import Enum
from subprocess import check_output
import distro
class Distributions(Enum):
@ -27,3 +36,46 @@ class Distributions(Enum):
SLACKWARE = 'slackware'
UBUNTU = 'ubuntu'
WINDOWS = 'windows'
@staticmethod
def run_detection():
"""Entry point of Archey distribution detection logic"""
# Are we running on Windows ?
if sys.platform in ('win32', 'cygwin'):
return Distributions.WINDOWS
# Is it a Windows Sub-system Linux (WSL) distribution ?
# If so, kernel release identifier should keep a trace of it.
if b'microsoft' in check_output(['uname', '-r']).lower():
return Distributions.WINDOWS
# Is `ID` (from `os-release`) well-known and supported ?
try:
return Distributions(distro.id())
except ValueError:
pass
# Is any of `ID_LIKE` (from `os-release`) well-known and supported ?
# See <https://www.freedesktop.org/software/systemd/man/os-release.html#ID_LIKE=>.
for id_like in distro.like().split(' '):
try:
return Distributions(id_like)
except ValueError:
continue
# At the moment, fall-back to default `Linux` if nothing of the above matched.
return Distributions.LINUX
@staticmethod
def get_distro_name():
"""Simple wrapper to `distro` to return the current distribution _pretty_ name"""
return distro.name(pretty=True) or None
@staticmethod
def get_ansi_color():
"""
Simple wrapper to `distro` to return the distribution preferred ANSI color.
See <https://www.freedesktop.org/software/systemd/man/os-release.html#ANSI_COLOR=>.
"""
return distro.os_release_attr('ansi_color') or None

@ -12,28 +12,37 @@ class CPU(Entry):
Parse `/proc/cpuinfo` file to retrieve the model name.
If no information could be retrieved, calls `lscpu`.
"""
_MODEL_NAME_REGEXP = re.compile(
r'^model name\s*:\s*(.*)$',
flags=re.IGNORECASE | re.MULTILINE
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
model_name_regex = re.compile(
r'^model name\s*:\s*(.*)$',
flags=re.IGNORECASE | re.MULTILINE
)
with open('/proc/cpuinfo') as file:
cpuinfo = re.search(model_name_regex, file.read())
# This test case has been built for some ARM architectures (see #29).
# Sometimes, `model name` info is not present within `/proc/cpuinfo`.
# We use the output of `lscpu` program (util-linux-ng) to retrieve it.
if not cpuinfo:
cpuinfo = re.search(
model_name_regex,
check_output(
['lscpu'],
env={'LANG': 'C'}, universal_newlines=True
)
)
cpuinfo_match = self._read_proc_cpuinfo()
if not cpuinfo_match:
# This test case has been built for some ARM architectures (see #29).
# Sometimes, `model name` info is not present within `/proc/cpuinfo`.
# We use the output of `lscpu` program (util-linux-ng) to retrieve it.
cpuinfo_match = self._run_lscpu()
# Sometimes CPU model name contains extra ugly white-spaces.
self.value = re.sub(r'\s+', ' ', cpuinfo.group(1))
self.value = re.sub(r'\s+', ' ', cpuinfo_match.group(1))
def _read_proc_cpuinfo(self):
"""Read `/proc/cpuinfo` and search for our model name pattern"""
try:
with open('/proc/cpuinfo') as f_cpu_info:
return self._MODEL_NAME_REGEXP.search(f_cpu_info.read())
except (PermissionError, FileNotFoundError):
return None
def _run_lscpu(self):
"""Same operation but from `lscpu` output"""
return self._MODEL_NAME_REGEXP.search(
check_output(
['lscpu'],
env={'LANG': 'C'}, universal_newlines=True
)
)

@ -2,8 +2,7 @@
from subprocess import check_output
import distro
from archey.distributions import Distributions
from archey.entry import Entry
@ -12,17 +11,13 @@ class Distro(Entry):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
distro_name = distro.name(pretty=True)
if not distro_name:
distro_name = None
architecture = check_output(
['uname', '-m'],
universal_newlines=True
).rstrip()
self.value = {
'name': distro_name,
'name': Distributions.get_distro_name(),
'arch': architecture
}

@ -86,8 +86,11 @@ class Model(Entry):
def _check_rasperry_pi(self):
"""Tries to retrieve 'Hardware' and 'Revision IDs' from `/proc/cpuinfo`"""
with open('/proc/cpuinfo') as f_cpu_info:
cpu_info = f_cpu_info.read()
try:
with open('/proc/cpuinfo') as f_cpu_info:
cpu_info = f_cpu_info.read()
except (PermissionError, FileNotFoundError):
return
# If the output contains 'Hardware' and 'Revision'...
hardware = re.search('(?<=Hardware\t: ).*', cpu_info)

@ -16,8 +16,24 @@ class RAM(Entry):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
used, total = self._run_free_dash_m()
if not total:
used, total = self._read_proc_meminfo()
if not total:
return
self.value = {
'used': used,
'total': total,
'unit': 'MiB'
}
@staticmethod
def _run_free_dash_m():
"""Call `free -m` and parse its output to retrieve current used and total RAM"""
try:
ram = ''.join(
memory_usage = ''.join(
filter(
re.compile('Mem').search,
check_output(
@ -26,37 +42,50 @@ class RAM(Entry):
).splitlines()
)
).split()
used = float(ram[2])
total = float(ram[1])
except (IndexError, FileNotFoundError):
# An in-digest one-liner to retrieve memory info into a dictionary
with open('/proc/meminfo') as file:
ram = {
i.split(':')[0]: float(i.split(':')[1].strip(' kB')) / 1024
for i in filter(None, file.read().splitlines())
}
return None, None
total = ram['MemTotal']
# Here, let's imitate Neofetch's behavior.
# See <https://github.com/dylanaraps/neofetch/wiki/Frequently-Asked-Questions>.
used = total + ram['Shmem'] - (
ram['MemFree'] + ram['Cached'] + ram['SReclaimable'] + ram['Buffers'])
# Imitates what `free` does when the obtained value happens to be incorrect.
# See <https://gitlab.com/procps-ng/procps/blob/master/proc/sysinfo.c#L790>.
if used < 0:
used = total - ram['MemFree']
return float(memory_usage[2]), float(memory_usage[1])
self.value = {
'used': used,
'total': total,
'unit': 'MiB'
}
@staticmethod
def _read_proc_meminfo():
"""Same behavior but by reading from `/proc/meminfo` directly"""
try:
with open('/proc/meminfo') as f_mem_info:
mem_info_lines = f_mem_info.read().splitlines()
except (PermissionError, FileNotFoundError):
return None, None
# Store memory information into a dictionary.
mem_info = {}
for line in filter(None, mem_info_lines):
key, value = line.split(':', maxsplit=1)
mem_info[key] = float(value.strip(' kB')) / 1024
total = mem_info['MemTotal']
# Here, let's imitate Neofetch's behavior.
# See <https://github.com/dylanaraps/neofetch/wiki/Frequently-Asked-Questions>.
used = total + mem_info['Shmem'] - (
mem_info['MemFree'] + mem_info['Cached']
+ mem_info['SReclaimable'] + mem_info['Buffers']
)
# Imitates what `free` does when the obtained value happens to be incorrect.
# See <https://gitlab.com/procps-ng/procps/blob/master/proc/sysinfo.c#L790>.
if used < 0:
used = total - mem_info['MemFree']
return used, total
def output(self, output):
"""
Adds the entry to `output` after pretty-formatting the RAM usage with colour and units.
Adds the entry to `output` after pretty-formatting the RAM usage with color and units.
"""
if not self.value:
# Fall back on the default behavior if no RAM usage could be detected.
super().output(output)
return
# DRY some constants
used = self.value['used']
total = self.value['total']

@ -67,7 +67,6 @@ class Terminal(Entry):
"""Build and return a 8-color palette, with Unicode characters if allowed"""
# On systems with non-Unicode locales, we imitate '\u2588' character
# ... with '#' to display the terminal colors palette.
# This is the default option for backward compatibility.
use_unicode = self._configuration.get('colors_palette')['use_unicode']
return ' '.join([

@ -4,16 +4,11 @@ It supports entries lazy-insertion, logo detection, and final printing.
"""
from textwrap import TextWrapper
from subprocess import check_output
from shutil import get_terminal_size
import os
import sys
import distro
from archey.api import API
from archey.colors import ANSI_ECMA_REGEXP, Colors
from archey.constants import COLOR_DICT, LOGOS_DICT
@ -31,30 +26,14 @@ class Output:
# Fetches passed arguments.
self._format_to_json = kwargs.get('format_to_json')
# First we check whether the Kernel has been compiled as a WSL.
if 'microsoft' in check_output(['uname', '-r'], universal_newlines=True).lower():
self._distribution = Distributions.WINDOWS
else:
try:
self._distribution = Distributions(distro.id())
except ValueError:
# See <https://www.freedesktop.org/software/systemd/man/os-release.html#ID_LIKE=>.
for distro_like in distro.like().split(' '):
try:
self._distribution = Distributions(distro_like)
except ValueError:
continue
break
else:
# Well, we didn't match anything so let's fall-back to default `Linux`.
self._distribution = Distributions.LINUX
# Run distribution detection and fetch a local reference to the
self._distribution = Distributions.run_detection()
# Fetch the colors palette related to this distribution.
self._colors_palette = COLOR_DICT[self._distribution]
# If `os-release`'s `ANSI_COLOR` option is set, honor it.
# See <https://www.freedesktop.org/software/systemd/man/os-release.html#ANSI_COLOR=>.
ansi_color = distro.os_release_attr('ansi_color')
ansi_color = Distributions.get_ansi_color()
if ansi_color and Configuration().get('colors_palette')['honor_ansi_color']:
# Replace each Archey integrated colors by `ANSI_COLOR`.
self._colors_palette = len(self._colors_palette) * \

@ -1,9 +1,8 @@
"""Simple class (acting as a singleton) to handle processes listing"""
import os
import sys
from subprocess import CalledProcessError, DEVNULL, check_output
from subprocess import check_output
from archey.singleton import Singleton
@ -13,19 +12,7 @@ class Processes(metaclass=Singleton):
def __init__(self):
try:
self._processes = check_output(
[
'ps',
(('-u' + str(os.getuid())) if os.getuid() != 0 else '-ax'),
'-o', 'comm',
'--no-headers'
],
universal_newlines=True, stderr=DEVNULL
).splitlines()
except CalledProcessError:
# The available `ps` implementation may not support passed parameters (hello BusyBox).
# Let's fall-back on a much simpler approach.
self._processes = check_output(
['ps', '-o', 'comm'],
['ps', '-eo', 'comm'],
universal_newlines=True
).splitlines()[1:]
except FileNotFoundError:

86
archey/screenshot.py Normal file

@ -0,0 +1,86 @@
"""Simple module doing its best as taking a screenshot of the current screen"""
import os
import sys
import time
from contextlib import ExitStack
from datetime import datetime
from functools import partial
from subprocess import CalledProcessError, DEVNULL, check_call
def take_screenshot(output_file=None):
"""
Simple function trying to take a screenshot using various famous back-end programs.
When supported by the found and available back-end, try to honor `output_file`.
"""
if not output_file or os.path.isdir(output_file):
# When a directory is provided, we've to force `output_file` to represent a **file** path.
output_file = os.path.join(
(output_file or os.getcwd()),
datetime.now().strftime('archey4_screenshot_%Y-%m-%d_%H.%M.%S.png')
)
# Some programs don't accept specific filename as parameters.
# In such cases, we may provide them a target directory instead.
output_dir = os.path.dirname(output_file)
# Back-end programs that _may_ (?) be available across different platforms.
screenshot_tools = {
'Flameshot': ['flameshot', 'full', '-p', output_dir],
'ImageMagick': ['import', '-window', 'root', output_file],
'scrot': ['scrot', '-z', output_file],
'Shutter': ['shutter', '-f', '-o', output_file, '-e'],
}
# Extends the original screenshot tools dictionary according to current platform.
if sys.platform in ('win32', 'cygwin'):
screenshot_tools['SnippingTool'] = ['SnippingTool.exe', '/clip']
elif sys.platform == 'darwin':
screenshot_tools['ScreenCapture'] = [
'screencapture',
'-x',
'-t', output_file.rpart('.')[2],
output_file
]
else: # *NIX systems (and others)...
screenshot_tools['GNOME-Screenshot'] = ['gnome-screenshot', '-f', output_file]
screenshot_tools['grim'] = ['grim', output_file]
screenshot_tools['KDE-Spectacle'] = ['spectacle', '-b', '-o', output_file]
screenshot_tools['Xfce4-Screenshooter'] = ['xfce4-screenshooter', '-f', '-s', output_dir]
# This part purposefully blocks so we wait a little bit before taking the screenshot.
# It prevents taking a screenshot before Archey's output has appeared.
taking_sc_fstring = '\rTaking screenshot in {:1d}...'
for time_remaining in range(3, 0, -1):
print(taking_sc_fstring.format(time_remaining), end='', flush=True)
time.sleep(1)
print('\r' + ' ' * len(taking_sc_fstring), end='\r', flush=True)
time.sleep(0.5)
with ExitStack() as defer_stack:
for screenshot_tool, screenshot_cmd in screenshot_tools.items():
try:
check_call(screenshot_cmd, stderr=DEVNULL)
except FileNotFoundError:
continue
except CalledProcessError as process_error:
defer_stack.callback(partial(
print,
'Couldn\'t take a screenshot with {}: \"{}\".'.format(
screenshot_tool, process_error
),
file=sys.stderr
))
continue
break
else:
defer_stack.callback(partial(
print,
"""\
Sorry, we couldn\'t find any supported program to take a screenshot on your system.
Please install one of the following and try again: {}.\
""".format(', '.join(screenshot_tools.keys())),
file=sys.stderr
))

@ -4,60 +4,70 @@ import os
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
from archey.configuration import Configuration
# To avoid edge-case issues due to singleton, we automatically reset internal `_instances`.
# This is done at the class-level.
@patch.dict(
'archey.singleton.Singleton._instances',
clear=True
)
class TestConfigurationUtil(unittest.TestCase):
"""
Simple test cases to check the behavior of `Configuration` tools.
We can't use the `patch` method as the dictionary state after
the initializations is unknown due to user's configuration files.
Simple test cases to check the behavior of `Configuration` singleton utility class.
Values will be manually set in the tests below.
"""
@patch(
# `_load_configuration` method is mocked to "ignore" local system configurations.
'archey.configuration.Configuration._load_configuration',
Mock()
)
def test_get(self):
"""Test the `get` binder method to configuration elements"""
"""Test the `get` binding method to configuration elements"""
configuration = Configuration()
configuration._config = { # pylint: disable=protected-access
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False
}
}
self.assertEqual(
configuration.get('ip_settings')['lan_ip_max_count'],
2
)
self.assertFalse(
configuration.get('temperature')['use_fahrenheit']
)
self.assertTrue(configuration.get('does_not_exist', True))
self.assertIsNone(configuration.get('does_not_exist_either'))
with patch.dict(
configuration._config, # pylint: disable=protected-access
{
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False
}
}
):
self.assertEqual(configuration.get('ip_settings')['lan_ip_max_count'], 2)
self.assertFalse(configuration.get('temperature')['use_fahrenheit'])
self.assertTrue(configuration.get('does_not_exist', True))
self.assertIsNone(configuration.get('does_not_exist_either'))
def test_load_configuration(self):
"""Test for configuration loading from file, and overriding flag"""
configuration = Configuration()
configuration._config = { # pylint: disable=protected-access
'allow_overriding': True,
'suppress_warnings': False,
'colors_palette': {
'use_unicode': False
},
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False
}
}
with tempfile.TemporaryDirectory(suffix='/') as temp_dir:
# We create a fake temporary configuration file
with open(temp_dir + 'config.json', 'w') as file:
file.write("""\
with patch.dict(
configuration._config, # pylint: disable=protected-access
{
'allow_overriding': True,
'suppress_warnings': False,
'colors_palette': {
'use_unicode': False
},
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False
}
},
clear=True
), \
tempfile.TemporaryDirectory() as temp_dir:
# We create a fake temporary configuration file.
with open(os.path.join(temp_dir, 'config.json'), 'w') as f_config:
f_config.write("""\
{
"allow_overriding": false,
"suppress_warnings": true,
@ -73,8 +83,8 @@ class TestConfigurationUtil(unittest.TestCase):
}
""")
# Let's load it into our `Configuration` instance
configuration.load_configuration(temp_dir)
# Let's load it into our `Configuration` instance.
configuration._load_configuration(temp_dir) # pylint: disable=protected-access
# Let's check the result :S
self.assertDictEqual(
@ -93,12 +103,14 @@ class TestConfigurationUtil(unittest.TestCase):
}
}
)
# The `stderr` file descriptor has changed due to
# the `suppress_warnings` option.
self.assertNotEqual(configuration._stderr, sys.stderr) # pylint: disable=protected-access
# The `stderr` file descriptor has changed due to the `suppress_warnings` option.
self.assertNotEqual(
configuration._stderr, # pylint: disable=protected-access
sys.stderr
)
# Let's try to load the `config.json` file present in this project.
configuration.load_configuration(os.getcwd() + '/archey/')
configuration._load_configuration('archey/') # pylint: disable=protected-access
# It should not happen as `allow_overriding` has been set to false.
# Thus, the configuration is supposed to be the same as before.
@ -122,77 +134,113 @@ class TestConfigurationUtil(unittest.TestCase):
def test_update_recursive(self):
"""Test for the `_update_recursive` private method"""
configuration = Configuration()
configuration._config = { # pylint: disable=protected-access
'allow_overriding': True,
'suppress_warnings': False,
'default_strings': {
'no_address': 'No Address',
'not_detected': 'Not detected'
},
'colors_palette': {
'use_unicode': False
},
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False
}
}
# We change existing values, add new ones, and omit some others.
configuration._update_recursive( # pylint: disable=protected-access
configuration._config, # pylint: disable=protected-access
{
'suppress_warnings': True,
'colors_palette': {
'use_unicode': False
with patch.dict(
configuration._config, # pylint: disable=protected-access
{
'allow_overriding': True,
'suppress_warnings': False,
'default_strings': {
'no_address': 'No Address',
'not_detected': 'Not detected'
},
'colors_palette': {
'use_unicode': False
},
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False
}
},
'default_strings': {
'no_address': '\xde\xad \xbe\xef',
'not_detected': 'Not detected',
'virtual_environment': 'Virtual Environment'
},
'temperature': {
'a_weird_new_dict': [
None,
'l33t',
{
'really': 'one_more_?'
}
]
clear=True
):
# We change existing values, add new ones, and omit some others.
configuration._update_recursive( # pylint: disable=protected-access
configuration._config, # pylint: disable=protected-access
{
'suppress_warnings': True,
'colors_palette': {
'use_unicode': False
},
'default_strings': {
'no_address': '\xde\xad \xbe\xef',
'not_detected': 'Not detected',
'virtual_environment': 'Virtual Environment'
},
'temperature': {
'a_weird_new_dict': [
None,
'l33t',
{
'really': 'one_more_?'
}
]
}
}
}
)
)
self.assertDictEqual(
configuration._config, # pylint: disable=protected-access
{
'allow_overriding': True,
'suppress_warnings': True,
'colors_palette': {
'use_unicode': False
},
'default_strings': {
'no_address': '\xde\xad \xbe\xef',
'not_detected': 'Not detected',
'virtual_environment': 'Virtual Environment'
},
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False,
'a_weird_new_dict': [
None,
'l33t',
{
'really': 'one_more_?'
}
]
self.assertDictEqual(
configuration._config, # pylint: disable=protected-access
{
'allow_overriding': True,
'suppress_warnings': True,
'colors_palette': {
'use_unicode': False
},
'default_strings': {
'no_address': '\xde\xad \xbe\xef',
'not_detected': 'Not detected',
'virtual_environment': 'Virtual Environment'
},
'ip_settings': {
'lan_ip_max_count': 2
},
'temperature': {
'use_fahrenheit': False,
'a_weird_new_dict': [
None,
'l33t',
{
'really': 'one_more_?'
}
]
}
}
}
)
)
def test_instantiation_config_path(self):
"""Test for configuration loading from specific user-defined path"""
with tempfile.TemporaryDirectory() as temp_dir:
# We create a fake temporary configuration file.
config_file = os.path.join(temp_dir, 'user.cfg') # A pure arbitrary name.
with open(config_file, 'w') as f_config:
f_config.write("""\
{
"allow_overriding": false,
"suppress_warnings": true,
"colors_palette": {
"use_unicode": false
},
"ip_settings": {
"lan_ip_max_count": 4
},
"temperature": {
"use_fahrenheit": true
}
}
""")
configuration = Configuration(config_path=config_file)
# We can't use `assertDictEqual` here as the resulting `_config` internal object
# directly depends on the default one (which constantly evolves).
# We safely check that above entries have correctly been overridden.
self.assertFalse(configuration.get('allow_overriding'))
self.assertTrue(configuration.get('suppress_warnings'))
self.assertFalse(configuration.get('colors_palette')['use_unicode'])
self.assertEqual(configuration.get('ip_settings')['lan_ip_max_count'], 4)
self.assertTrue(configuration.get('temperature')['use_fahrenheit'])
if __name__ == '__main__':

@ -77,9 +77,35 @@ model name\t: CPU MODEL\t NAME
create=True
)
def test_spaces_squeezing(self):
"""Test name sanitizing, needed on some platformd"""
"""Test name sanitizing, needed on some platforms"""
self.assertEqual(CPU().value, 'CPU MODEL NAME')
@patch(
'archey.entries.cpu.open',
side_effect=PermissionError(),
create=True
)
@patch(
'archey.entries.cpu.check_output',
return_value="""\
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: X
Core(s) per socket: Y
Socket(s): 1
NUMA node(s): 1
Vendor ID: CPU-VENDOR-NAME
CPU family: Z
Model: \xde\xad\xbe\xef
Model name: CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO
""")
def test_proc_cpuinfo_unreadable(self, _, __):
"""Check Archey does not crash when `/proc/cpuinfo` is not readable"""
self.assertEqual(CPU().value, 'CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO')
if __name__ == '__main__':
unittest.main()

@ -0,0 +1,153 @@
"""Test module for `archey.distributions`"""
import unittest
from unittest.mock import patch
from archey.distributions import Distributions
class TestDistributionsUtil(unittest.TestCase):
"""
Test cases for the `Distributions` (enumeration / utility) class.
"""
def test_constant_values(self):
"""Test enumeration member instantiation from value"""
self.assertEqual(Distributions('debian'), Distributions.DEBIAN)
self.assertRaises(ValueError, Distributions, 'unknown')
@patch(
'archey.distributions.sys.platform',
'win32'
)
def test_detection_windows(self):
"""Test output for Windows"""
self.assertEqual(
Distributions.run_detection(),
Distributions.WINDOWS
)
@patch(
'archey.distributions.sys.platform',
'linux'
)
@patch(
'archey.distributions.check_output',
return_value=b'X.Y.Z-R-Microsoft\n'
)
def test_detection_windows_subsystem(self, _):
"""Test output for Windows Subsystem Linux"""
self.assertEqual(
Distributions.run_detection(),
Distributions.WINDOWS
)
@patch(
'archey.distributions.sys.platform',
'linux'
)
@patch(
'archey.distributions.check_output',
return_value=b'X.Y.Z-R-ARCH\n'
)
@patch(
'archey.distributions.distro.id',
return_value='debian'
)
def test_detection_known_distro_id(self, _, __):
"""Test known distribution output"""
self.assertEqual(
Distributions.run_detection(),
Distributions.DEBIAN
)
@patch(
'archey.distributions.sys.platform',
'linux'
)
@patch(
'archey.distributions.check_output',
return_value=b'X.Y.Z-R-ARCH\n'
)
@patch(
'archey.distributions.distro.id',
return_value='an-unknown-distro-id'
)
@patch(
'archey.distributions.distro.like',
return_value='' # No `ID_LIKE` specified.
)
def test_detection_unknown_distro_id(self, _, __, ___):
"""Test unknown distribution output"""
self.assertEqual(
Distributions.run_detection(),
Distributions.LINUX
)
@patch(
'archey.distributions.sys.platform',
'linux'
)
@patch(
'archey.distributions.check_output',
return_value=b'X.Y.Z-R-ARCH\n'
)
@patch(
'archey.distributions.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.distributions.distro.like',
return_value='ubuntu' # Oh, it's actually an Ubuntu-based one !
)
def test_detection_known_distro_like(self, _, __, ___):
"""Test distribution matching from the `os-release`'s `ID_LIKE` option"""
self.assertEqual(
Distributions.run_detection(),
Distributions.UBUNTU
)
@patch(
'archey.distributions.sys.platform',
'linux'
)
@patch(
'archey.distributions.check_output',
return_value=b'X.Y.Z-R-ARCH\n'
)
@patch(
'archey.distributions.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.distributions.distro.like',
return_value='an-unknown-distro-id arch' # Hmmm, an unknown Arch-based...
)
def test_detection_distro_like_second(self, _, __, ___):
"""Test distribution matching from the `os-release`'s `ID_LIKE` option (second candidate)"""
self.assertEqual(
Distributions.run_detection(),
Distributions.ARCH_LINUX
)
@patch(
'archey.distributions.sys.platform',
'linux'
)
@patch(
'archey.distributions.check_output',
return_value=b'X.Y.Z-R-ARCH\n'
)
@patch(
'archey.distributions.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.distributions.distro.like',
return_value='' # No `ID_LIKE` either...
)
def test_detection_both_distro_calls_fail(self, _, __, ___):
"""Test distribution fall-back when `distro` soft-fail two times"""
self.assertEqual(
Distributions.run_detection(),
Distributions.LINUX
)

@ -7,16 +7,16 @@ from archey.entries.distro import Distro
class TestDistroEntry(unittest.TestCase):
"""We mock the `distro` vendor module call, as long as the `check_output` one"""
@patch(
'archey.entries.distro.distro.name', # `distro.name` output
return_value="""\
NAME VERSION (CODENAME)\
""")
"""`Distro` entry simple test cases"""
@patch(
'archey.entries.distro.check_output', # `uname` output
return_value="""\
ARCHITECTURE
""")
@patch(
'archey.entries.distro.Distributions.get_distro_name',
return_value="""\
NAME VERSION (CODENAME)\
""")
def test_ok(self, _, __):
"""Test for `distro` and `uname` retrievals"""
@ -28,15 +28,15 @@ ARCHITECTURE
}
)
@patch(
'archey.entries.distro.distro.name', # `distro.name` output
return_value="" # `distro` is soft-failing, returning an empty string...
)
@patch(
'archey.entries.distro.check_output', # `uname` output
return_value="""\
ARCHITECTURE
""")
@patch(
'archey.entries.distro.Distributions.get_distro_name',
return_value=None # Soft-failing : No _pretty_ distribution name found...
)
@patch(
'archey.configuration.Configuration.get',
return_value={'not_detected': 'Not detected'}

@ -260,13 +260,18 @@ class TestLanIpEntry(unittest.TestCase, CustomAssertions):
'archey.entries.lan_ip.netifaces',
None # Imitate an `ImportError` behavior.
)
@patch(
'archey.entries.lan_ip.print',
return_value=None, # Let's nastily mute class' outputs.
create=True
)
@patch(
'archey.configuration.Configuration.get',
side_effect=[
{'not_detected': 'Not detected'}
]
)
def test_netifaces_not_available(self, _):
def test_netifaces_not_available(self, _, __):
"""Check `netifaces` is really acting as a (soft-)dependency"""
lan_ip = LanIp()

@ -15,9 +15,6 @@ class TestModelEntry(unittest.TestCase):
* Raspberry Pi
* Virtual environment (as a VM or a container)
"""
def setUp(self):
self._return_values = None
@patch(
'archey.entries.model.check_output',
side_effect=CalledProcessError(1, 'systemd-detect-virt', "none\n")
@ -37,13 +34,12 @@ class TestModelEntry(unittest.TestCase):
)
def test_raspberry(self, _):
"""Test for a typical Raspberry context"""
self._return_values = [
FileNotFoundError(), # First `open` call will fail
'Hardware\t: HARDWARE\nRevision\t: REVISION\n'
]
with patch('archey.entries.model.open', mock_open(), create=True) as mock:
mock.return_value.read.side_effect = self._special_func_for_mock_open
mock.return_value.read.side_effect = [
FileNotFoundError(), # First `open` call will (`/sys/[...]/product_name`)
'Hardware\t: HARDWARE\nRevision\t: REVISION\n'
]
self.assertEqual(
Model().value,
'Raspberry Pi HARDWARE (Rev. REVISION)'
@ -136,13 +132,11 @@ class TestModelEntry(unittest.TestCase):
)
def test_no_match(self, _, __, ___):
"""Test when no information could be retrieved"""
self._return_values = [
FileNotFoundError(), # First `open` call will fail
'Hardware\t: HARDWARE\n' # `Revision` entry is not present
]
with patch('archey.entries.model.open', mock_open(), create=True) as mock:
mock.return_value.read.side_effect = self._special_func_for_mock_open
mock.return_value.read.side_effect = [
FileNotFoundError(), # First `open` call will (`/sys/[...]/product_name`)
PermissionError() # `/proc/cpuinfo` won't be available
]
model = Model()
@ -156,22 +150,5 @@ class TestModelEntry(unittest.TestCase):
)
def _special_func_for_mock_open(self):
"""
This method does not belong to the test cases.
It's a special method which allows mocking multiple `io.open` calls.
You just have to set the values within a list `self._return_values`.
And then :
`mock.return_value.read.side_effect = self._special_func_for_mock_open`
"""
return_value = self._return_values.pop(0)
# Either return value or raise any specified exception
if issubclass(return_value.__class__, OSError().__class__):
raise return_value
return return_value
if __name__ == '__main__':
unittest.main()

@ -13,178 +13,11 @@ from archey.distributions import Distributions
class TestOutputUtil(unittest.TestCase):
"""
Simple test cases to check the behavior of `Output` main class.
Simple test cases to check the behavior of the `Output` class.
"""
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='debian'
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_known_distro(self, _, __, ___):
"""Test known distribution output"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.DEBIAN
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='an-unknown-distro-id'
)
@patch(
'archey.output.distro.like',
return_value='' # No `ID_LIKE` specified.
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_unknown_distro(self, _, __, ___, ____):
"""Test unknown distribution output"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.LINUX
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.output.distro.like',
return_value='ubuntu' # Oh, it's actually an Ubuntu-based one !
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_distro_like(self, _, __, ___, ____):
"""Test distribution matching from the `os-release`'s `ID_LIKE` option"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.UBUNTU
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.output.distro.like',
return_value='linuxmint debian' # Oh, what do we got there ?!
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_distro_like_first(self, _, __, ___, ____):
"""Test distribution matching from the `os-release`'s `ID_LIKE` option (multiple entries)"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.LINUX_MINT
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.output.distro.like',
return_value='an-unknown-distro-id arch' # Hmmm, an unknown Arch-based...
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_distro_like_second(self, _, __, ___, ____):
"""Test distribution matching from the `os-release`'s `ID_LIKE` option (second candidate)"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.ARCH_LINUX
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='' # Unknown distribution.
)
@patch(
'archey.output.distro.like',
return_value='' # No `ID_LIKE` either...
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_both_distro_calls_fail(self, _, __, ___, ____):
"""Test distribution fall-back when `distro` soft-fail two times"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.LINUX
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-Microsoft\n'
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
)
def test_init_windows_subsystem(self, _, __):
"""Test output for Windows Subsystem Linux"""
output = Output()
self.assertEqual(
output._distribution, # pylint: disable=protected-access
Distributions.WINDOWS
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
)
@patch(
'archey.output.distro.id',
return_value='debian' # Make Debian being selected.
'archey.output.Distributions.run_detection',
return_value=Distributions.DEBIAN # Make Debian being selected.
)
@patch.dict(
'archey.output.COLOR_DICT',
@ -194,7 +27,7 @@ class TestOutputUtil(unittest.TestCase):
'archey.output.Configuration.get',
return_value={'honor_ansi_color': False}
)
def test_append_regular(self, _, __, ___):
def test_append_regular(self, _, __):
"""Test the `append` method, for new entries"""
output = Output()
output.append('KEY', 'VALUE')
@ -205,22 +38,18 @@ class TestOutputUtil(unittest.TestCase):
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
'archey.output.Distributions.run_detection',
return_value=Distributions.SLACKWARE # Make Slackware being selected.
)
@patch(
'archey.output.distro.id',
return_value='slackware' # Make Slackware being selected.
)
@patch(
'archey.output.distro.os_release_attr',
'archey.output.Distributions.get_ansi_color',
return_value='ANSI_COLOR'
)
@patch(
'archey.output.Configuration.get',
return_value={'honor_ansi_color': True}
)
def test_append_ansi_color(self, _, __, ___, ____):
def test_append_ansi_color(self, _, __, ___):
"""Check that `Output` honor `ANSI_COLOR` as required"""
output = Output()
@ -234,11 +63,11 @@ class TestOutputUtil(unittest.TestCase):
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-Microsoft\n' # Make WSL detection pass.
'archey.output.Distributions.run_detection',
return_value=Distributions.WINDOWS # Make WSL detection pass.
)
@patch(
'archey.output.distro.os_release_attr',
'archey.output.Distributions.get_ansi_color',
return_value='ANSI_COLOR'
)
@patch(
@ -259,16 +88,12 @@ class TestOutputUtil(unittest.TestCase):
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
'archey.output.Distributions.run_detection',
return_value=Distributions.DEBIAN # Make Debian being selected.
)
@patch(
'archey.output.distro.id',
return_value='debian' # Make Debian being selected.
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
'archey.output.Distributions.get_ansi_color',
return_value=None
)
@patch.dict(
'archey.output.LOGOS_DICT',
@ -306,7 +131,7 @@ class TestOutputUtil(unittest.TestCase):
return_value=None, # Let's nastily mute class' outputs.
create=True
)
def test_centered_output(self, print_mock, _, __, ___):
def test_centered_output(self, print_mock, _, __):
"""Test how the `output` method handles centering operations"""
output = Output()
@ -329,7 +154,7 @@ class TestOutputUtil(unittest.TestCase):
)
# Entries bigger than logo
output._results = [ # pylint: disable=protected-access
output._results = [ # pylint: disable=protected-access
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11',
'12', '13', '14', '15', '16', '17', '18', '19', '20', '21'
]
@ -377,7 +202,7 @@ FAKE_COLOR 21\x1b[0m\
)
# Entries bigger than logo
output._results = [ # pylint: disable=protected-access
output._results = [ # pylint: disable=protected-access
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12',
'13', '14', '15', '16', '17', '18', '19', '20', '21', '22'
]
@ -436,16 +261,12 @@ FAKE_COLOR 22\x1b[0m\
)
@patch(
'archey.output.check_output',
return_value='X.Y.Z-R-ARCH\n'
'archey.output.Distributions.run_detection',
return_value=Distributions.DEBIAN # Make Debian being selected.
)
@patch(
'archey.output.distro.id',
return_value='debian' # Select Debian
)
@patch(
'archey.output.distro.os_release_attr',
return_value=''
'archey.output.Distributions.get_ansi_color',
return_value=None
)
@patch.dict(
'archey.output.LOGOS_DICT',
@ -465,7 +286,7 @@ FAKE_COLOR 22\x1b[0m\
return_value=None, # Let's nastily mute class' outputs.
create=True
)
def test_line_wrapping(self, print_mock, termsize_mock, _, __, ___):
def test_line_wrapping(self, print_mock, termsize_mock, _, __):
"""Test how the `output` method handles wrapping lines that are too long"""
output = Output()
@ -493,6 +314,10 @@ O \x1b[0;31m\x1b[0m...\x1b[0m\
# `unittest.mock.Mock.assert_called_once` is not available against Python < 3.6.
self.assertEqual(print_mock.call_count, 1)
@patch(
'archey.output.Distributions.run_detection',
return_value=Distributions.DEBIAN # Make Debian being selected.
)
@patch(
'archey.output.Configuration.get',
return_value={'honor_ansi_color': False}
@ -502,7 +327,7 @@ O \x1b[0;31m\x1b[0m...\x1b[0m\
return_value=None, # Let's nastily mute class' outputs.
create=True
)
def test_json_output_format(self, print_mock, _):
def test_json_output_format(self, print_mock, _, __):
"""Test how the `output` method handles JSON preferred formatting of entries"""
output = Output(format_to_json=True)
# We can't set the `name` attribute of a mock on its creation,
@ -514,7 +339,7 @@ O \x1b[0;31m\x1b[0m...\x1b[0m\
mocked_entries[0].name = 'test'
mocked_entries[1].name = 'name'
output._entries = mocked_entries # pylint: disable=protected-access
output._entries = mocked_entries # pylint: disable=protected-access
output.output()
# Check that `print` output is properly formatted as JSON, with expected results.

@ -1,26 +1,27 @@
"""Test module for `archey.processes`"""
from subprocess import CalledProcessError
import unittest
from unittest.mock import patch
from archey.processes import Processes
# To avoid edge-case issues due to singleton, we automatically reset internal `_instances`.
# This is done at the class-level.
@patch.dict(
'archey.singleton.Singleton._instances',
clear=True
)
class TestProcessesUtil(unittest.TestCase):
"""
Test cases for the `Processes` (singleton) class.
To work around the singleton, we reset the internal `_instances` dictionary.
This way, `check_output` can be mocked here.
"""
@patch.dict(
'archey.singleton.Singleton._instances',
clear=True
)
@patch(
'archey.processes.check_output',
return_value="""\
COMMAND
what
an
awesome
@ -45,31 +46,6 @@ there
# `unittest.mock.Mock.assert_called_once` is not available against Python < 3.6.
self.assertEqual(check_output_mock.call_count, 1)
@patch.dict(
'archey.singleton.Singleton._instances',
clear=True
)
@patch(
'archey.processes.check_output',
side_effect=[
CalledProcessError(1, 'ps', "ps: unrecognized option: u\n"),
"""\
COMMAND
sh
top
ps
"""])
def test_ps_failed(self, _):
"""Verifies that the program correctly handles first crashing `ps` call"""
self.assertListEqual(
Processes().get(),
['sh', 'top', 'ps']
)
@patch.dict(
'archey.singleton.Singleton._instances',
clear=True
)
@patch(
'archey.processes.check_output',
side_effect=FileNotFoundError()

@ -142,6 +142,34 @@ SUnreclaim: 113308 kB
}
)
@patch(
'archey.entries.ram.check_output',
side_effect=IndexError() # `free` call will fail
)
@patch(
'archey.entries.ram.open',
side_effect=PermissionError(),
create=True
)
@patch(
'archey.configuration.Configuration.get',
side_effect=[
{'not_detected': 'Not detected'}
]
)
def test_not_detected(self, _, __, ___):
"""Check Archey does not crash when `/proc/meminfo` is not readable"""
ram = RAM()
output_mock = MagicMock()
ram.output(output_mock)
self.assertIsNone(ram.value)
self.assertEqual(
output_mock.append.call_args[0][1],
'Not detected'
)
if __name__ == '__main__':
unittest.main()

@ -335,14 +335,19 @@ class TestTemperatureEntry(unittest.TestCase):
)
def test_celsius_to_fahrenheit_conversion(self, _, __, ___):
"""Simple tests for the `_convert_to_fahrenheit` static method"""
temperature = Temperature()
# pylint: disable=protected-access
self.assertAlmostEqual(temperature._convert_to_fahrenheit(-273.15), -459.67)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(0.0), 32.0)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(21.0), 69.8)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(37.0), 98.6)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(100.0), 212.0)
# pylint: enable=protected-access
test_conversion_cases = [
(-273.15, -459.67),
(0.0, 32.0),
(21.0, 69.8),
(37.0, 98.6),
(100.0, 212.0)
]
for celsius_value, expected_fahrenheit in test_conversion_cases:
self.assertAlmostEqual(
Temperature._convert_to_fahrenheit(celsius_value), # pylint: disable=protected-access
expected_fahrenheit
)
if __name__ == '__main__':

@ -26,8 +26,7 @@ class TestTerminalEntry(unittest.TestCase):
)
def test_terminal_emulator_term_program(self, _):
"""Check that `TERM_PROGRAM` is honored even if `TERM` or `COLORTERM` is defined"""
output = Terminal().value
self.assertTrue(output.startswith('A-COOL-TERMINAL-EMULATOR'))
self.assertEqual(Terminal().value, 'A-COOL-TERMINAL-EMULATOR')
@patch.dict(
'archey.entries.terminal.os.environ',
@ -43,8 +42,7 @@ class TestTerminalEntry(unittest.TestCase):
)
def test_terminal_emulator_special_term(self, _):
"""Check that `TERM` is honored even if a "known identifier" could be found"""
output = Terminal().value
self.assertTrue(output.startswith('OH-A-SPECIAL-CASE'))
self.assertEqual(Terminal().value, 'OH-A-SPECIAL-CASE')
@patch.dict(
'archey.entries.terminal.os.environ',
@ -60,8 +58,7 @@ class TestTerminalEntry(unittest.TestCase):
)
def test_terminal_emulator_name_normalization(self, _):
"""Check that our manual terminal detection as long as name normalization are working"""
output = Terminal().value
self.assertTrue(output.startswith('Konsole'))
self.assertEqual(Terminal().value, 'Konsole')
@patch.dict(
'archey.entries.terminal.os.environ',
@ -79,7 +76,7 @@ class TestTerminalEntry(unittest.TestCase):
output_mock = MagicMock()
terminal.output(output_mock)
self.assertTrue(terminal.value.startswith('xterm-256color'))
self.assertEqual(terminal.value, 'xterm-256color')
self.assertTrue(
output_mock.append.call_args[0][1].startswith('xterm-256color')
)
@ -99,8 +96,7 @@ class TestTerminalEntry(unittest.TestCase):
)
def test_terminal_emulator_colorterm(self, _):
"""Check we can detect terminals using the `COLORTERM` environment variable."""
output = Terminal().value
self.assertTrue(output.startswith('KMSCON'))
self.assertEqual(Terminal().value, 'KMSCON')
@patch.dict(
'archey.entries.terminal.os.environ',
@ -119,8 +115,7 @@ class TestTerminalEntry(unittest.TestCase):
"""
Check we observe terminal using `COLORTERM` even if `TERM` or a "known identifier" is found.
"""
output = Terminal().value
self.assertTrue(output.startswith('KMSCON'))
self.assertEqual(Terminal().value, 'KMSCON')
@patch.dict(
'archey.entries.terminal.os.environ',
@ -143,7 +138,7 @@ class TestTerminalEntry(unittest.TestCase):
output = output_mock.append.call_args[0][1]
self.assertIsNone(terminal.value)
self.assertTrue(output.startswith('Not detected '))
self.assertTrue(output.startswith('Not detected'))
self.assertFalse(output.count('\u2588'))

@ -7,3 +7,12 @@ set -e
if [ -L /usr/bin/archey4 ]; then
rm /usr/bin/archey4
fi
# Removes any byte-code file that may have been generated by Archey.
# Wild-cards are being used to match all supported distribution layouts.
find /usr/lib/python3*/*-packages/archey \
-type d \
-name __pycache__ \
-exec \
rm -r {} +

@ -20,10 +20,6 @@
# Run it as :
# $ bash packaging/build.sh [REVISION] [0xGPG_IDENTITY]
#
# Known packages error (FPM bug ?) :
# * Arch Linux :
# * `--pacman-optional-depends` appears to be ignored [jordansissel/fpm#1619]
#
# If you happen to tweak packaging scripts, please lint them before submitting changes :
# $ shellcheck packaging/*
#
@ -58,12 +54,12 @@ FPM_COMMON_ARGS=(
--config-files "etc/archey4/config.json" \
--architecture all \
--maintainer "${AUTHOR} <${AUTHOR_EMAIL}>" \
--after-install packaging/after_install \
--after-upgrade packaging/after_install \
--before-remove packaging/before_remove \
--after-install ./packaging/after_install \
--after-upgrade ./packaging/after_install \
--before-remove ./packaging/before_remove \
--python-bin python3 \
--python-install-bin usr/bin/ \
--python-install-data usr/ \
--python-install-bin 'usr/bin/' \
--python-install-data 'usr/' \
--no-python-fix-name \
--no-python-dependencies \
)
@ -82,6 +78,10 @@ sed -e "s/\${DATE}/$(date +'%B %Y')/1" archey.1 | \
# Prevent Setuptools from generating byte-code files.
# Important note :
# It allows the packager to build generic distribution packages without shipping byte-code related to its Python interpreter version.
# A noticeable side-effect may be the appearance of "untracked" byte-code files (when running Archey as root for instance).
# Check `packaging/before_remove` script to see how Archey deals with them.
export PYTHONDONTWRITEBYTECODE=1
@ -95,7 +95,7 @@ fpm \
--depends 'python3 >= 3.4' \
--depends 'python3-distro' \
--depends 'python3-netifaces' \
--python-install-lib usr/lib/python3/dist-packages/ \
--python-install-lib 'usr/lib/python3/dist-packages/' \
--deb-priority 'optional' \
--deb-field 'Suggests: dnsutils, lm-sensors, pciutils, wmctrl, virt-what, btrfs-progs' \
--deb-no-default-config-files \
@ -170,13 +170,13 @@ python3 setup.py -q sdist bdist_wheel
# Check whether packages description will render correctly on PyPI.
echo 'Now checking PyPI description rendering...'
if twine check dist/*.{tar.gz,whl}; then
if twine check ./dist/*.{tar.gz,whl}; then
echo -n 'Upload source and wheel distribution packages to PyPI ? [y/N] '
read -r -n 1 -p '' && echo
if [[ "$REPLY" =~ ^[yY]$ ]]; then
echo 'Now signing & uploading source TAR and WHEEL to PyPI...'
twine upload \
--sign --identity "$GPG_IDENTITY" \
dist/*.{tar.gz,whl}
./dist/*.{tar.gz,whl}
fi
fi

@ -23,6 +23,7 @@ setup(
license='GPLv3',
packages=find_packages(exclude=['archey.test']),
test_suite='archey.test',
python_requires='>=3.4',
install_requires=[
'distro',
'netifaces'
@ -60,6 +61,7 @@ Remain *maintained*, *community-driven* and *highly-compatible* with yesterday's
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Topic :: System'
]
)