Compare commits

...

47 Commits

Author SHA1 Message Date
Lucidiot 1f7cac1d8c
Update pre-commit hooks
continuous-integration/drone/push Build is passing Details
2023-05-09 13:06:37 +02:00
Lucidiot ac6a1b3103
Add Python 3.11 in CI
continuous-integration/drone Build is failing Details
2023-05-09 12:48:18 +02:00
~lucidiot 04e3354394
Split PyPI secrets
continuous-integration/drone/push Build is passing Details
2022-08-09 14:19:14 +02:00
~lucidiot 91918e8d41
Bump to 0.4.3
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone Build is passing Details
2022-08-05 01:53:28 +02:00
~lucidiot e3a89613e1
Updates due to Tildegit move 2022-08-05 01:53:08 +02:00
~lucidiot 4438f17e94
Bump to 0.4.2 2022-02-08 22:44:56 +01:00
~lucidiot 70b155cf04
Fix missing files in source packages 2022-02-08 22:43:02 +01:00
~lucidiot d6becffcc0
Bump to 0.4.1 2022-01-26 19:19:40 +01:00
~lucidiot 27cc5de1b9 Merge branch 'manifest' into 'master'
Add MANIFEST.in

Closes #21

See merge request Lucidiot/pylspci!19
2022-01-26 18:19:26 +00:00
~lucidiot 1936c58dec
Add MANIFEST.in 2022-01-26 19:17:26 +01:00
~lucidiot 544edd0b3d
Bump to 0.4.0 2021-09-21 20:12:18 +02:00
~lucidiot da39b09ef5 Merge branch 'bump-python' into 'master'
Bump CI image to Python 3.9

See merge request Lucidiot/pylspci!18
2021-09-21 18:09:03 +00:00
~lucidiot 149b3bde99
Bump CI image to Python 3.9 2021-09-21 20:06:55 +02:00
~lucidiot ad52e4c823 Merge branch 'docs-cleanup' into 'master'
Remove unnecessary types in Sphinx docstrings

See merge request Lucidiot/pylspci!17
2021-09-21 18:05:37 +00:00
~lucidiot 30e1ed5ec4
Remove unnecessary types in Sphinx docstrings 2021-09-21 20:03:21 +02:00
~lucidiot 7cebd6202d Merge branch 'as-dict' into 'master'
Add dict serialization methods

Closes #20

See merge request Lucidiot/pylspci!16
2021-09-21 18:02:32 +00:00
~lucidiot 57d96ab345
Add dict serialization methods 2021-09-21 19:51:28 +02:00
~lucidiot 386b7fc326 Merge branch 'bump-pre-commit' into 'master'
Bump pre-commit hooks

See merge request Lucidiot/pylspci!15
2021-09-21 17:48:51 +00:00
~lucidiot 587a55af73
Bump pre-commit hooks 2021-09-21 19:46:45 +02:00
~lucidiot 96cbe0e72c Merge branch 'fix-setup' into 'master'
Fix setup.py test for Alpine build

See merge request Lucidiot/pylspci!14
2021-09-21 17:03:26 +00:00
~lucidiot 09256806f1
Fix setup.py test for Alpine build
This removes some unnecessary dependencies installed by setup.py test,
fixing an issue found in Alpine's package building CI:

https://gitlab.alpinelinux.org/Lucidiot/aports/-/jobs/492430
2021-09-21 01:35:01 +02:00
Lucidiot 27695dd747 Bump to 0.3.4 2021-01-26 17:28:44 +00:00
Lucidiot 0b00a2e586 Update physical_slot docstring 2021-01-26 17:27:20 +00:00
Lucidiot 06906411a1 Merge branch 'change_phy_slot_to_str' into 'master'
Changed physical_slot to str, because linux adds '-#' if duplicate slot numbers are found

See merge request Lucidiot/pylspci!13
2021-01-26 17:21:41 +00:00
Jan Lützler 9f90cf8519 Changed physical_slot to str, because linux adds '-#' if duplicate slot numbers are found 2021-01-26 17:21:41 +00:00
Lucidiot 7778e1a48a
Bump to 0.3.3 2020-11-29 19:55:19 +01:00
Lucidiot 51aac8692d
Update badge count 2020-11-29 19:54:56 +01:00
Lucidiot 11c57ae0f1
Remove dependency of pages deployment on unit tests 2020-11-29 19:54:10 +01:00
Lucidiot 7da6c0756c Merge branch 'add-verbose-error' into 'master'
Prevent using SimpleParser with verbose=True

Closes #18

See merge request Lucidiot/pylspci!12
2020-11-29 18:52:49 +00:00
Lucidiot 45dfdd7277
Prevent using SimpleParser with verbose=True 2020-11-29 19:45:48 +01:00
Lucidiot a34c08c9ce
Add isort 2020-11-29 19:17:36 +01:00
Lucidiot ce1abd76d0
Fix typing of kwargs 2020-11-29 19:06:57 +01:00
Lucidiot 0b06017ab7
Add pre-commit 2020-11-29 19:06:28 +01:00
Lucidiot 92417f1b6a
Fix intro examples 2020-11-29 18:02:13 +01:00
Lucidiot 65053cfdba
Remove GitHub badges 2020-11-29 18:00:37 +01:00
Lucidiot 3b93a30fb4
Add missing type checking docs 2020-11-29 17:59:56 +01:00
Lucidiot 4c21f2f813 Bump to 0.3.2 2020-08-06 09:35:45 +00:00
Lucidiot f21296378a Merge branch 'iommugroup' into 'master'
Add support for IOMMUGroup, closes #17

Closes #17

See merge request Lucidiot/pylspci!11
2020-08-06 09:32:43 +00:00
Erwan Rouchet 4406ce90a7
Add support for IOMMUGroup, closes #17 2020-08-06 11:30:47 +02:00
Lucidiot 4abe4a883a
Add test coverage badge 2020-01-22 19:40:43 +01:00
Lucidiot 44dad5738f
Bump to 0.3.1 2020-01-22 19:23:04 +01:00
Lucidiot c9e34b2a5e
Add PhySlot field 2020-01-22 19:21:12 +01:00
Lucidiot 3c3bbe25c5
Warn about unknown fields instead of erroring 2020-01-22 19:16:43 +01:00
Lucidiot fc5d3c7644
Add optional NUMANode field 2020-01-22 19:07:41 +01:00
Lucidiot cddd1d04a7
Add unit tests info to contribution docs 2019-10-03 06:40:59 +02:00
Lucidiot 45cceb8611 Merge branch 'typing' into 'master'
Typing with mypy

Closes #15 and #14

See merge request Lucidiot/pylspci!10
2019-09-06 21:09:50 +00:00
Lucidiot ba2557bca1 Typing with mypy 2019-09-06 21:09:50 +00:00
32 changed files with 760 additions and 359 deletions

120
.drone.yml Normal file
View File

@ -0,0 +1,120 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: pre-commit
image: python:3-alpine
commands:
- apk add --no-cache git gcc musl-dev
- pip install .[dev]
- pre-commit run -a
- name: test-py36
image: python:3.6-alpine
commands:
- pip install .[dev]
- coverage run setup.py test
- coverage report
- name: test-py37
image: python:3.7-alpine
commands:
- pip install .[dev]
- coverage run setup.py test
- coverage report
- name: test-py38
image: python:3.8-alpine
commands:
- pip install .[dev]
- coverage run setup.py test
- coverage report
- name: test-py39
image: python:3.9-alpine
commands:
- pip install .[dev]
- coverage run setup.py test
- coverage report
- name: test-py310
image: python:3.10-alpine
commands:
- pip install .[dev]
- coverage run setup.py test
- coverage report
- name: test-py311
image: python:3.11-alpine
commands:
- pip install .[dev]
- coverage run setup.py test
- coverage report
- name: testpypi
image: python:3.11-alpine
commands:
- pip install .[dev] twine setuptools wheel
- |
echo "[distutils]
index-servers = testpypi
[testpypi]
repository=https://test.pypi.org/legacy/
username=$$TESTPYPI_DEPLOY_USERNAME
password=$$TESTPYPI_DEPLOY_PASSWORD" > ~/.pypirc
- python setup.py sdist bdist_wheel
- twine upload dist/* -r testpypi
when:
event:
- promote
repo:
- lucidiot/pylspci
depends_on:
- pre-commit
- test-py36
- test-py37
- test-py38
- test-py39
- test-py310
- test-py311
environment:
TESTPYPI_DEPLOY_USERNAME:
from_secret: testpypi_username
TESTPYPI_DEPLOY_PASSWORD:
from_secret: testpypi_password
- name: pypi
image: python:3.11-alpine
commands:
- pip install .[dev] twine setuptools wheel
- |
echo "[distutils]
index-servers = pypi
[pypi]
repository=https://upload.pypi.org/legacy/
username=$$PYPI_DEPLOY_USERNAME
password=$$PYPI_DEPLOY_PASSWORD" > ~/.pypirc
- python setup.py sdist bdist_wheel
- twine upload dist/* -r pypi
when:
event:
- promote
repo:
- lucidiot/pylspci
branch:
- master
depends_on:
- testpypi
environment:
PYPI_DEPLOY_USERNAME:
from_secret: pypi_username
PYPI_DEPLOY_PASSWORD:
from_secret: pypi_password

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
.mypy_cache/
profile_default/
ipython_config.py

View File

@ -1,92 +0,0 @@
image: python:3.7
stages:
- test
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- venv/
before_script:
- pip install virtualenv
- virtualenv venv
- source venv/bin/activate
- pip install .[dev]
tests:
stage: test
coverage: '/TOTAL[\s\d]+\s(\d+%)/'
script:
- coverage run setup.py test
- coverage report
- codecov
flake8:
stage: test
script:
- flake8
doc8:
stage: test
script:
- doc8
deploy-pypi:
stage: deploy
when: manual
only:
- master@Lucidiot/pylspci
environment:
name: pypi
url: https://pypi.org/project/pylspci
script:
- pip install twine setuptools wheel
- echo "[distutils]" > ~/.pypirc
- echo "index-servers =" >> ~/.pypirc
- echo " pypi" >> ~/.pypirc
- echo "[pypi]" >> ~/.pypirc
- echo "repository=https://upload.pypi.org/legacy/" >> ~/.pypirc
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
- python setup.py sdist bdist_wheel
- twine upload dist/* -r pypi
deploy-testpypi:
stage: deploy
when: manual
only:
- branches@Lucidiot/pylspci
environment:
name: testpypi
url: https://test.pypi.org/project/pylspci
script:
- pip install twine setuptools wheel
- echo "[distutils]" > ~/.pypirc
- echo "index-servers =" >> ~/.pypirc
- echo " testpypi" >> ~/.pypirc
- echo "[testpypi]" >> ~/.pypirc
- echo "repository=https://test.pypi.org/legacy/" >> ~/.pypirc
- echo "username=$PYPI_DEPLOY_USERNAME" >> ~/.pypirc
- echo "password=$PYPI_DEPLOY_PASSWORD" >> ~/.pypirc
- python setup.py sdist bdist_wheel
- twine upload dist/* -r testpypi
pages:
stage: deploy
when: manual
only:
- master@Lucidiot/pylspci
artifacts:
paths:
- public
script:
- cd docs
- make html
- mv _build/html ../public

35
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,35 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- id: check-executables-have-shebangs
- id: check-symlinks
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.2.0
hooks:
- id: mypy
args:
- --ignore-missing-imports
- --disallow-incomplete-defs
- --disallow-untyped-defs
- --check-untyped-defs
- --no-implicit-optional
- repo: https://github.com/PyCQA/doc8
rev: v1.1.1
hooks:
- id: doc8
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort

6
MANIFEST.in Normal file
View File

@ -0,0 +1,6 @@
include requirements.txt
include requirements-dev.txt
include VERSION
include LICENSE
include README.rst
include pylspci/py.typed

View File

@ -5,4 +5,4 @@ A Python parser for the ``lspci`` command from the pciutils_ package.
`Browse documentation`_
.. _pciutils: http://mj.ucw.cz/sw/pciutils/
.. _Browse documentation: https://lucidiot.gitlab.io/pylspci/
.. _Browse documentation: https://lucidiot.tildepages.org/pylspci/

View File

@ -1 +1 @@
0.3.0
0.4.3

View File

@ -16,4 +16,4 @@ help:
# 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)
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@ -130,4 +130,3 @@ PCI access
``-H2``
Access hardware using Intel configuration mechanism 2.
Alias to ``-A intel-conf2``.

View File

@ -14,13 +14,14 @@
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
# -- Project information -----------------------------------------------------
project = 'pylspci'
copyright = '2019, Lucidiot and contributors'
copyright = '2022, Lucidiot and contributors'
author = 'Lucidiot and contributors'
# The short X.Y version
@ -61,7 +62,7 @@ master_doc = 'index'
#
# 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
language = 'en'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@ -112,23 +113,23 @@ htmlhelp_basename = 'pylspcidoc'
# -- 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',
}
# 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,

View File

@ -6,8 +6,8 @@ Contributions to the project are greatly appreciated.
Bugs and suggestions
--------------------
You may `submit an issue`_ to GitLab to warn of any bugs, ask for new features,
or ask any questions that are not answered in this documentation.
You may `submit an issue`_ to the Gitea repository to warn of any bugs, ask for
new features, or ask any questions that are not answered in this documentation.
When reporting a bug, do not forget to put in your version of Python and your
version of *pylspci*. This will greatly help when troubleshooting, as most
@ -22,7 +22,7 @@ Setup
You will need a virtual envionment to work properly. `virtualenvwrapper`_ is
recommended::
git clone https://gitlab.com/Lucidiot/pylspci
git clone https://tildegit.org/lucidiot/pylspci.git
cd pylspci
mkvirtualenv -a . pylspci
pip install -e .[dev]
@ -31,6 +31,37 @@ This will clone the repository, create a virtual environment named
``pylspci``, then tell pip to let the package be editable (``-e``).
The ``[dev]`` suffix adds the extra requirements useful for development.
Unit tests
^^^^^^^^^^
Unit tests use the standard ``unittest`` package; you may run them using the
standard ``setup.py`` command::
./setup.py test
Tests coverage
^^^^^^^^^^^^^^
I aim for 100% coverage on all of my Python packages whenever I add unit
tests to them; this package is no exception. CI checks use the `coverage`_
Python package to get coverage statistics.
To get test coverage data locally, run::
coverage run setup.py test
You may then get a short coverage summary in your terminal::
coverage report
Or generate an HTML report in a ``htmlcov`` folder, which can be browsed
offline using your favorite web browser and shows line by line coverage::
coverage html
If you are having issues reaching 100% coverage, try to still add some tests,
and mention your issues when creating a pull request to the
`Gitea repository`_.
Linting
^^^^^^^
@ -38,17 +69,25 @@ The source code follows the PEP 8 code style and performs CI checks using the
``flake8`` tool. To perform the same checks locally, run ``flake8`` on the root
directory of this repository.
Type checking
^^^^^^^^^^^^^
The source code uses PEP 484 type hints and type checking is performed in CI
using ``mypy``. To run those checks locally, run ``mypy .`` on the root
directory of this repository.
Documentation
-------------
The documentation you are reading is generated by the `Sphinx`_ tool.
The text files that hold the documentation's contents are written in
`reStructuredText`_ and are available under the ``/docs`` folder of the
`GitLab repository`_.
`Gitea repository`_.
They are also subject to linting using the ``doc8`` tool.
.. _submit an issue: https://gitlab.com/Lucidiot/pylspci/issues/new
.. _submit an issue: https://tildegit.org/lucidiot/pylspci/issues/new
.. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io
.. _GitLab repository: https://gitlab.com/Lucidiot/pylspci
.. _coverage: https://coverage.readthedocs.io/
.. _Gitea repository: https://tildegit.org/lucidiot/pylspci
.. _Sphinx: http://www.sphinx-doc.org/
.. _reStructuredText: http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html

View File

@ -18,17 +18,11 @@ Python lspci parser
.. image:: https://img.shields.io/pypi/status/pylspci.svg
:target: https://pypi.org/project/pylspci
.. image:: https://gitlab.com/Lucidiot/pylspci/badges/master/pipeline.svg
:target: https://gitlab.com/Lucidiot/pylspci/pipelines
.. image:: https://drone.tildegit.org/api/badges/lucidiot/pylspci/status.svg
:target: https://drone.tildegit.org/api/badges/lucidiot/pylspci/status.svg
.. image:: https://requires.io/github/Lucidiot/pylspci/requirements.svg?branch=master
:target: https://requires.io/github/Lucidiot/pylspci/requirements/?branch=master
.. image:: https://img.shields.io/github/last-commit/Lucidiot/pylspci.svg
:target: https://gitlab.com/Lucidiot/pylspci/commits
.. image:: https://img.shields.io/badge/badge%20count-9-brightgreen.svg
:target: https://gitlab.com/Lucidiot/pylspci
.. image:: https://img.shields.io/badge/badge%20count-7-brightgreen.svg
:target: https://tildegit.org/lucidiot/pylspci
A Python parser for the ``lspci`` command from the pciutils_ package.
@ -60,7 +54,7 @@ To parse ``lspci -nnmmvvvk``, use the
.. code:: python
>>> from pylspci.parsers import SimpleParser
>>> SimpleParser.run()
>>> SimpleParser().run()
[Device(slot=Slot('0000:00:01.3'), name=NameWithID('Name A [2420]'), ...),
Device(slot=Slot('0000:00:01.4'), name=NameWithID('Name B [0e54]'), ...)]
@ -70,8 +64,8 @@ Custom arguments
.. code:: python
>>> from pylspci.command import IDResolveOption
>>> from pylspci import parser
>>> parser.run(
>>> from pylspci.parsers import VerboseParser
>>> VerboseParser().run(
... hide_single_domain=False,
... id_resolve_option=IDResolveOption.NameOnly,
... )

View File

@ -1,10 +1,11 @@
#!/usr/bin/env python3
from pathlib import Path
from typing import Optional, List
from pylspci.command import CommandBuilder, IDResolveOption
from pylspci.filters import SlotFilter, DeviceFilter
import argparse
import json
from pathlib import Path
from typing import Any, Dict, Optional
from pylspci.command import CommandBuilder, IDResolveOption
from pylspci.filters import DeviceFilter, SlotFilter
def get_parser() -> argparse.ArgumentParser:
@ -156,15 +157,14 @@ def get_parser() -> argparse.ArgumentParser:
return parser
def main():
def main() -> None:
parser: argparse.ArgumentParser = get_parser()
args: dict = vars(parser.parse_args())
args: Dict[str, Any] = vars(parser.parse_args())
# Specific parsing required
use_parser: bool = args.pop('json', True)
json_output: bool = args.pop('json', True)
kernel_modules: bool = args.pop('kernel_modules', False)
access_method: Optional[str] = args.pop('access_method', None)
pcilib_params: List[str] = args.pop('pcilib_params', []) or []
pcilib_params = args.pop('pcilib_params', []) or []
builder: CommandBuilder = CommandBuilder(**args)
if kernel_modules:
@ -178,7 +178,7 @@ def main():
for param in pcilib_params:
if param.strip().lower() == 'help':
builder = builder.list_pcilib_params(raw=not use_parser)
builder = builder.list_pcilib_params(raw=not json_output)
break
if '=' not in param:
parser.error(
@ -186,24 +186,19 @@ def main():
key, value = map(str.strip, param.split('=', 2))
builder = builder.with_pcilib_params(**{key: value})
if use_parser:
if json_output:
builder = builder.with_default_parser()
result = list(builder)
if not use_parser: # Raw mode
if not json_output: # Raw mode
for item in result:
print(item)
return
def _item_handler(item):
if hasattr(item, '_asdict'):
return item._asdict()
return item
print(json.dumps(
list(map(_item_handler, result)),
default=vars,
))
print(json.dumps([
item if isinstance(item, str) else item.as_dict()
for item in result
]))
if __name__ == '__main__':

View File

@ -1,10 +1,14 @@
import subprocess
from enum import Enum
from typing import Optional, Union, Tuple, List, Mapping, Any, Iterator
from pathlib import Path
from typing import (
Any, Iterator, List, Mapping, MutableMapping, Optional, Union
)
from pylspci.device import Device
from pylspci.fields import PCIAccessParameter
from pylspci.filters import SlotFilter, DeviceFilter
import subprocess
from pylspci.filters import DeviceFilter, SlotFilter
from pylspci.parsers.base import Parser
OptionalPath = Optional[Union[str, Path]]
@ -140,7 +144,7 @@ def lspci(
)
def list_access_methods():
def list_access_methods() -> List[str]:
"""
Calls ``lspci(access_method='help')`` to list the PCI access methods
the underlying ``pcilib`` provides and parses the human-readable list into
@ -157,7 +161,7 @@ def list_access_methods():
))
def list_pcilib_params_raw():
def list_pcilib_params_raw() -> List[str]:
"""
Calls ``lspci -Ohelp`` to list the PCI access parameters the underlying
``pcilib`` provides.
@ -176,7 +180,7 @@ def list_pcilib_params_raw():
))
def list_pcilib_params():
def list_pcilib_params() -> List[PCIAccessParameter]:
"""
Calls ``lspci -Ohelp`` to list the PCI access parameters the underlying
``pcilib`` provides and parse the human-readable list into
@ -202,14 +206,14 @@ class CommandBuilder(object):
_list_access_methods: bool = False
_list_pcilib_params: bool = False
_list_pcilib_params_raw: bool = False
_params: Mapping[str, Any] = {}
_parser = None
_params: MutableMapping[str, Any] = {}
_parser: Optional[Parser] = None
def __init__(self, **kwargs: Mapping[str, Any]):
def __init__(self, **kwargs: Any):
self._params = kwargs
def __iter__(self) -> Iterator[Union[str, Device, PCIAccessParameter]]:
result = None
result: Union[str, List[str], List[Device], List[PCIAccessParameter]]
if self._list_access_methods:
result = list_access_methods()
elif self._list_pcilib_params:
@ -317,7 +321,9 @@ class CommandBuilder(object):
self._list_access_methods = False
return self
def with_pcilib_params(self, *args, **kwargs) -> 'CommandBuilder':
def with_pcilib_params(self,
*args: Mapping[str, Any],
**kwargs: Any) -> 'CommandBuilder':
"""
Override some pcilib parameters. When given a dict, will rewrite the
parameters with the new dict. When given keyword arguments, will update
@ -448,7 +454,7 @@ class CommandBuilder(object):
return self
def slot_filter(self,
*args: Tuple[str],
*args: str,
domain: Optional[int] = None,
bus: Optional[int] = None,
device: Optional[int] = None,
@ -478,7 +484,7 @@ class CommandBuilder(object):
return self
def device_filter(self,
*args: Tuple[str],
*args: str,
cls: Optional[int] = None,
vendor: Optional[int] = None,
device: Optional[int] = None) -> 'CommandBuilder':
@ -502,7 +508,7 @@ class CommandBuilder(object):
DeviceFilter(cls=cls, vendor=vendor, device=device)
return self
def with_parser(self, parser=None) -> 'CommandBuilder':
def with_parser(self, parser: Optional[Parser] = None) -> 'CommandBuilder':
"""
Use a pylspci parser to get parsed Device instances instead of strings.

View File

@ -1,5 +1,15 @@
from typing import NamedTuple, Optional, List
from pylspci.fields import Slot, NameWithID
from typing import Dict, List, NamedTuple, Optional, Union
from pylspci.fields import NameWithID, NameWithIDDict, Slot, SlotDict
DeviceDict = Dict[str, Union[
int,
str,
SlotDict,
NameWithIDDict,
List[str],
None,
]]
class Device(NamedTuple):
@ -10,69 +20,92 @@ class Device(NamedTuple):
slot: Slot
"""
The device's slot (domain, bus, number and function).
:type: Slot
"""
cls: NameWithID
"""
The device's class, with a name and/or an ID.
:type: NameWithID
"""
vendor: NameWithID
"""
The device's vendor, with a name and/or an ID.
:type: NameWithID
"""
device: NameWithID
"""
The device's name and/or ID.
:type: NameWithID
"""
subsystem_vendor: Optional[NameWithID] = None
"""
The device's subsystem vendor, if found, with a name and/or an ID.
:type: NameWithID or None
"""
subsystem_device: Optional[NameWithID] = None
"""
The device's subsystem name and/or ID, if found.
:type: NameWithID or None
"""
revision: Optional[int] = None
"""
The device's revision number.
:type: int or None
"""
progif: Optional[int] = None
"""
The device's programming interface number.
:type: int or None
"""
driver: Optional[str] = None
"""
The device's driver (Linux only).
:type: str or None
"""
kernel_modules: List[str] = []
"""
One or more kernel modules that can handle this device (Linux only).
:type: List[str] or None
"""
numa_node: Optional[int] = None
"""
NUMA node this device is connected to (Linux only).
"""
iommu_group: Optional[int] = None
"""
IOMMU group that this device is part of (optional, Linux only).
"""
physical_slot: Optional[str] = None
"""
The device's physical slot number (Linux only).
"""
def as_dict(self) -> DeviceDict:
"""
Serialize this device as a JSON-serializable `dict`.
"""
return {
"slot": self.slot.as_dict(),
"cls": self.cls.as_dict(),
"vendor": self.vendor.as_dict(),
"device": self.device.as_dict(),
"subsystem_vendor": (
self.subsystem_vendor.as_dict()
if self.subsystem_vendor
else None
),
"subsystem_device": (
self.subsystem_device.as_dict()
if self.subsystem_device
else None
),
"revision": self.revision,
"progif": self.progif,
"driver": self.driver,
"kernel_modules": self.kernel_modules,
"numa_node": self.numa_node,
"iommu_group": self.iommu_group,
"physical_slot": self.physical_slot,
}

View File

@ -1,7 +1,11 @@
from functools import partial
from typing import Optional, Any
import re
from functools import partial
from typing import Any, Dict, Optional, Union
# mypy does not support recursive type definitions
# SlotDict = Dict[str, Union[int, 'SlotDict', None]]
SlotDict = Dict[str, Union[int, Dict[str, Any], None]]
NameWithIDDict = Dict[str, Union[int, str, None]]
hexstring = partial(int, base=16)
@ -18,36 +22,26 @@ class Slot(object):
"""
The slot's domain, as a four-digit hexadecimal number.
When omitted, defaults to ``0x0000``.
:type: int
"""
bus: int
"""
The slot's bus, as a two-digit hexadecimal number.
:type: int
"""
device: int
"""
The slot's device, as a two-digit hexadecimal number, up to `0x1f`.
:type: int
"""
function: int
"""
The slot's function, as a single octal digit.
:type: int
"""
parent: Optional["Slot"] = None
"""
The slot's parent bridge, if present.
:type: Slot or None
"""
def __init__(self, value: str) -> None:
@ -76,6 +70,18 @@ class Slot(object):
def __repr__(self) -> str:
return '{}({!r})'.format(self.__class__.__name__, str(self))
def as_dict(self) -> SlotDict:
"""
Serialize this slot as a JSON-serializable `dict`.
"""
return {
"domain": self.domain,
"bus": self.bus,
"device": self.device,
"function": self.function,
"parent": self.parent.as_dict() if self.parent else None,
}
class NameWithID(object):
"""
@ -86,15 +92,11 @@ class NameWithID(object):
id: Optional[int]
"""
The PCI ID as a four-digit hexadecimal number.
:type: int or None
"""
name: Optional[str]
"""
The human-readable name associated with this ID.
:type: str or None
"""
_NAME_ID_REGEX = re.compile(r'^(?P<name>.+)\s\[(?P<id>[0-9a-fA-F]{4})\]$')
@ -132,6 +134,15 @@ class NameWithID(object):
def __repr__(self) -> str:
return '{}({!r})'.format(self.__class__.__name__, str(self))
def as_dict(self) -> NameWithIDDict:
"""
Serialize this name and ID as a JSON-serializable `dict`.
"""
return {
"id": self.id,
"name": self.name,
}
class PCIAccessParameter(object):
"""
@ -143,22 +154,16 @@ class PCIAccessParameter(object):
name: str
"""
The parameter's name.
:type: str
"""
description: str
"""
A short description of the parameter's use.
:type: str
"""
default: Optional[str]
"""
An optional default value for the parameter.
:type: str or None
"""
_PARAM_REGEX = re.compile(
@ -184,3 +189,13 @@ class PCIAccessParameter(object):
return isinstance(other, PCIAccessParameter) and \
(self.name, self.description, self.default) \
== (other.name, other.description, other.default)
def as_dict(self) -> Dict[str, Optional[str]]:
"""
Serialize this PCI access parameter as a JSON-serializable `dict`.
"""
return {
"name": self.name,
"description": self.description,
"default": self.default,
}

View File

@ -1,7 +1,10 @@
from abc import ABC
from typing import Optional, Pattern, ClassVar
from pylspci.fields import hexstring
import re
from abc import ABC, abstractmethod
from typing import Any, ClassVar, Dict, Optional, Pattern, Type, TypeVar
from pylspci.fields import hexstring
T = TypeVar('T', bound='Filter')
class Filter(ABC):
@ -9,10 +12,11 @@ class Filter(ABC):
_REGEX: ClassVar[Pattern]
@classmethod
def parse(cls, value: str) -> 'Filter':
def parse(cls: Type[T], value: str) -> T:
if not value:
return cls()
match, data = cls._REGEX.match(value), {}
match = cls._REGEX.match(value)
data: Dict[str, str] = {}
if match:
data = {k: v for k, v in match.groupdict().items()
if v is not None}
@ -24,6 +28,10 @@ class Filter(ABC):
if v != '' and v != '*'
})
@abstractmethod
def __init__(self, **kwargs: Any) -> None:
"Create a filter."
class SlotFilter(Filter):
"""
@ -35,29 +43,21 @@ class SlotFilter(Filter):
domain: Optional[int] = None
"""
Device domain, as a four-digit hexadecimal number.
:type: int or None
"""
bus: Optional[int] = None
"""
Device bus, as a two-digit hexadecimal number.
:type: int or None
"""
device: Optional[int] = None
"""
Device number, as a two-digit hexadecimal number, up to `0x1f`.
:type: int or None
"""
function: Optional[int] = None
"""
The slot's function, as a single octal digit.
:type: int or None
"""
# [[domain:]bus:][device][.function]
@ -92,9 +92,10 @@ class SlotFilter(Filter):
))
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__) and \
self.domain, self.bus, self.device, self.function == \
other.domain, other.bus, other.device, other.function
if not isinstance(other, self.__class__):
return NotImplemented
return (self.domain, self.bus, self.device, self.function) == \
(other.domain, other.bus, other.device, other.function)
class DeviceFilter(Filter):
@ -107,22 +108,16 @@ class DeviceFilter(Filter):
cls: Optional[int] = None
"""
Device class ID, as a four-digit hexadecimal number.
:type: int or None
"""
vendor: Optional[int] = None
"""
Device vendor ID, as a four-digit hexadecimal number.
:type: int or None
"""
device: Optional[int] = None
"""
Device ID, as a four-digit hexadecimal number.
:type: int or None
"""
# [vendor]:[device][:class]
@ -153,6 +148,7 @@ class DeviceFilter(Filter):
))
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__) and \
self.vendor, self.device, self.cls == \
other.vendor, other.device, other.cls
if not isinstance(other, self.__class__):
return NotImplemented
return (self.vendor, self.device, self.cls) == \
(other.vendor, other.device, other.cls)

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod
from typing import Union, Iterable, List, Mapping, Any
from typing import Any, Dict, Iterable, List, Union
from pylspci.device import Device
from pylspci.command import lspci
class Parser(ABC):
default_lspci_args: Mapping[str, Any] = {}
default_lspci_args: Dict[str, Any] = {}
"""
The default arguments that, when sent to :func:`lspci`, should provide the
best output for this parser.
@ -15,18 +15,23 @@ class Parser(ABC):
"""
@abstractmethod
def parse(self, data: Union[str, Iterable[str]]) -> List[Device]:
def parse(
self,
data: Union[str, Iterable[str], Iterable[Iterable[str]]]
) -> List[Device]:
"""
Parse a string or list of strings as a list of devices.
:param data: A string holding multiple devices
or a list of strings, one for each device.
:type data: str or Iterable[str]
:param data: A string holding multiple devices,
a list of strings, one for each device,
or a list of lists of strings, one list for each device, with
each list holding each part of the device output.
:type data: str or Iterable[str] or Iterable[Iterable[str]]
:returns: A list of parsed devices.
:rtype: List[Device]
"""
def run(self, **kwargs: Mapping[str, Any]) -> List[Device]:
def run(self, **kwargs: Any) -> List[Device]:
"""
Run the lspci command with the given arguments, defaulting to the
parser's default arguments, and parse the result.
@ -34,10 +39,11 @@ class Parser(ABC):
:param \\**kwargs: Optional arguments to override the parser's default
arguments. See :func:`lspci`'s documentation for a list of
available arguments.
:type \\**kwargs: Mapping[str, Any]
:type \\**kwargs: Any
:returns: A list of parsed devices.
:rtype: List[Device]
"""
from pylspci.command import lspci
lspci_kwargs = self.default_lspci_args.copy()
lspci_kwargs.update(kwargs)
return self.parse(lspci(**lspci_kwargs))

View File

@ -1,10 +1,12 @@
from typing import Union, List, Iterable
from cached_property import cached_property
from pylspci.parsers.base import Parser
from pylspci.fields import hexstring, Slot, NameWithID
from pylspci.device import Device
import argparse
import shlex
from typing import Any, Iterable, List, Union
from cached_property import cached_property
from pylspci.device import Device
from pylspci.fields import NameWithID, Slot, hexstring
from pylspci.parsers.base import Parser
class SimpleParser(Parser):
@ -53,13 +55,19 @@ class SimpleParser(Parser):
)
return p
def parse(self, data: Union[str, Iterable[str]]) -> List[Device]:
def parse(
self,
data: Union[str, Iterable[str], Iterable[Iterable[str]]],
) -> List[Device]:
"""
Parse a multiline string or a list of single-line strings
from lspci -mm into devices.
:param data: String or list of strings to parse from.
:type data: str or Iterable[str]
:param data: A string holding multiple devices,
a list of strings, one for each device,
or a list of lists of strings, one list for each device, with
each list holding each part of the device output.
:type data: str or Iterable[str] or Iterable[Iterable[str]]
:return: A list of parsed devices.
:rtype: List[Device]
"""
@ -80,3 +88,11 @@ class SimpleParser(Parser):
if isinstance(args, str):
args = shlex.split(args)
return Device(**vars(self._parser.parse_args(args)))
def run(self, **kwargs: Any) -> List[Device]:
if kwargs.get('verbose'):
raise ValueError(
'Verbose output is unsupported from the SimpleParser. '
'Please use the pylspci.parsers.VerboseParser instead.'
)
return super().run(**kwargs)

View File

@ -1,7 +1,15 @@
from typing import Union, List, Iterable, NamedTuple, Callable, Any
from pylspci.parsers.base import Parser
import warnings
from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Union
from pylspci.device import Device
from pylspci.fields import hexstring, Slot, NameWithID
from pylspci.fields import NameWithID, Slot, hexstring
from pylspci.parsers.base import Parser
UNKNOWN_FIELD_WARNING = (
'Unsupported device field {!r} with value {!r}\n'
'Please report this, along with the output of `lspci -mmnnvvvk`, at '
'https://tildegit.org/lucidiot/pylspci/issues/new'
)
class FieldMapping(NamedTuple):
@ -13,23 +21,17 @@ class FieldMapping(NamedTuple):
field_name: str
"""
Field name on the :class:`Device` named tuple.
:type: str
"""
field_type: Callable[[str], Any]
"""
Field type; a callable to use to parse the string value.
:type: Callable[[str], Any]
"""
many: bool = False
"""
Whether or not to use a List, if this field can be repeated multiple times
in the lspci output.
:type: bool
"""
@ -65,17 +67,24 @@ class VerboseParser(Parser):
field_type=str,
many=True,
),
'NUMANode': FieldMapping(field_name='numa_node', field_type=int),
'IOMMUGroup': FieldMapping(field_name='iommu_group', field_type=int),
'PhySlot': FieldMapping(field_name='physical_slot', field_type=str),
}
def _parse_device(self, device_data: Union[str, Iterable[str]]) -> Device:
devdict = {}
devdict: Dict[str, Any] = {}
if isinstance(device_data, str):
device_data = device_data.splitlines()
for line in device_data:
key, _, value = map(str.strip, line.partition(':'))
assert key in self._field_mapping, \
'Unsupported key {!r}'.format(key)
if key not in self._field_mapping:
warnings.warn(
UNKNOWN_FIELD_WARNING.format(key, value),
UserWarning,
)
continue
field = self._field_mapping[key]
if field.many:
devdict.setdefault(field.field_name, []) \
@ -85,21 +94,30 @@ class VerboseParser(Parser):
return Device(**devdict)
def parse(self, data: Union[str, Iterable[str]]) -> List[Device]:
def parse(
self,
data: Union[str, Iterable[str], Iterable[Iterable[str]]],
) -> List[Device]:
"""
Parse an lspci -vvvmm[nnk] output, either as a single string holding
multiple devices separated by two newlines,
or as a list of multiline strings holding one device each.
:param data: One string holding a full lspci output,
or multiple strings holding one device each.
:type data: str or Iterable[str]
:param data: A string holding multiple devices,
a list of strings, one for each device,
or a list of lists of strings, one list for each device, with
each list holding each part of the device output.
:type data: str or Iterable[str] or Iterable[Iterable[str]]
:return: A list of parsed devices.
:rtype: List[Device]
"""
if isinstance(data, str):
data = data.split('\n\n')
return list(map(
self._parse_device,
filter(bool, map(str.strip, data)), # Ignore empty strings
))
result: List[Device] = []
for line in data:
if isinstance(line, str):
line = str.strip(line)
if not line: # Ignore empty strings and lists
continue
result.append(self._parse_device(line))
return result

0
pylspci/py.typed Normal file
View File

View File

@ -1,6 +1,7 @@
from unittest import TestCase
from unittest.mock import patch, call, MagicMock
from pathlib import Path
from unittest import TestCase
from unittest.mock import MagicMock, call, patch
from pylspci.command import CommandBuilder, IDResolveOption
from pylspci.parsers import SimpleParser, VerboseParser
@ -8,14 +9,14 @@ from pylspci.parsers import SimpleParser, VerboseParser
class TestCommandBuilder(TestCase):
@patch('pylspci.command.lspci')
def test_default(self, lspci_mock):
def test_default(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
self.assertListEqual(list(CommandBuilder()), ['a', 'b'])
self.assertEqual(lspci_mock.call_count, 1)
self.assertEqual(lspci_mock.call_args, call())
@patch('pylspci.command.lspci')
def test_iter_str(self, lspci_mock):
def test_iter_str(self, lspci_mock: MagicMock) -> None:
"""
Test iterating the CommandBuilder when lspci returns a single string
returns an iterator for a list made of a single string, not an iterator
@ -28,7 +29,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_use_pciids(self, lspci_mock, isfile_mock):
def test_use_pciids(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = True
builder = CommandBuilder().use_pciids('somefile')
@ -39,7 +42,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_use_pciids_check(self, lspci_mock, isfile_mock):
def test_use_pciids_check(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = False
with self.assertRaisesRegex(AssertionError, 'not found'):
@ -48,7 +53,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_use_pciids_no_check(self, lspci_mock, isfile_mock):
def test_use_pciids_no_check(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = False
builder = CommandBuilder().use_pciids('somefile', check=False)
@ -59,7 +66,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_use_pcimap(self, lspci_mock, isfile_mock):
def test_use_pcimap(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = True
builder = CommandBuilder().use_pcimap('somefile')
@ -70,7 +79,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_use_pcimap_check(self, lspci_mock, isfile_mock):
def test_use_pcimap_check(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = False
with self.assertRaisesRegex(AssertionError, 'not found'):
@ -79,7 +90,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_use_pcimap_no_check(self, lspci_mock, isfile_mock):
def test_use_pcimap_no_check(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = False
builder = CommandBuilder().use_pcimap('somefile', check=False)
@ -89,7 +102,7 @@ class TestCommandBuilder(TestCase):
self.assertFalse(isfile_mock.called)
@patch('pylspci.command.lspci')
def test_use_access_method(self, lspci_mock):
def test_use_access_method(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder() \
.use_access_method('one') \
@ -99,7 +112,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(lspci_mock.call_args, call(access_method='two'))
@patch('pylspci.command.list_access_methods')
def test_list_access_methods(self, list_mock):
def test_list_access_methods(self, list_mock: MagicMock) -> None:
list_mock.return_value = ['a', 'b']
builder = CommandBuilder().list_pcilib_params().list_access_methods()
self.assertListEqual(list(builder), ['a', 'b'])
@ -107,7 +120,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(list_mock.call_args, call())
@patch('pylspci.command.list_pcilib_params')
def test_list_pcilib_params(self, list_mock):
def test_list_pcilib_params(self, list_mock: MagicMock) -> None:
list_mock.return_value = ['a', 'b']
builder = CommandBuilder().list_access_methods().list_pcilib_params()
self.assertListEqual(list(builder), ['a', 'b'])
@ -115,7 +128,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(list_mock.call_args, call())
@patch('pylspci.command.list_pcilib_params_raw')
def test_list_pcilib_params_raw(self, list_mock):
def test_list_pcilib_params_raw(self, list_mock: MagicMock) -> None:
list_mock.return_value = ['a', 'b']
builder = CommandBuilder() \
.list_access_methods() \
@ -125,7 +138,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(list_mock.call_args, call())
@patch('pylspci.command.lspci')
def test_with_pcilib_params_dict(self, lspci_mock):
def test_with_pcilib_params_dict(self, lspci_mock: MagicMock) -> None:
with self.assertRaisesRegex(AssertionError, 'dict or keyword'):
CommandBuilder().with_pcilib_params({'a': 'b'}, c='d')
with self.assertRaisesRegex(AssertionError, 'Only one positional'):
@ -138,7 +151,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(lspci_mock.call_args, call(pcilib_params={'a': 'b'}))
@patch('pylspci.command.lspci')
def test_with_pcilib_params_kwargs(self, lspci_mock):
def test_with_pcilib_params_kwargs(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder() \
.with_pcilib_params(a='1', b='2') \
@ -151,7 +164,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_from_file(self, lspci_mock, isfile_mock):
def test_from_file(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = True
builder = CommandBuilder().from_file('somefile')
@ -162,7 +177,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_from_file_check(self, lspci_mock, isfile_mock):
def test_from_file_check(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = False
with self.assertRaisesRegex(AssertionError, 'not found'):
@ -171,7 +188,9 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.lspci')
def test_from_file_no_check(self, lspci_mock, isfile_mock):
def test_from_file_no_check(self,
lspci_mock: MagicMock,
isfile_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
isfile_mock.return_value = False
builder = CommandBuilder().from_file('somefile', check=False)
@ -181,7 +200,7 @@ class TestCommandBuilder(TestCase):
self.assertFalse(isfile_mock.called)
@patch('pylspci.command.lspci')
def test_verbose(self, lspci_mock):
def test_verbose(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder().verbose()
self.assertListEqual(list(builder), ['a', 'b'])
@ -189,7 +208,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(lspci_mock.call_args, call(verbose=True))
@patch('pylspci.command.lspci')
def test_include_kernel_drivers(self, lspci_mock):
def test_include_kernel_drivers(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder() \
.include_kernel_drivers(False) \
@ -202,7 +221,7 @@ class TestCommandBuilder(TestCase):
))
@patch('pylspci.command.lspci')
def test_include_bridge_paths(self, lspci_mock):
def test_include_bridge_paths(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder() \
.include_bridge_paths(False) \
@ -214,7 +233,7 @@ class TestCommandBuilder(TestCase):
))
@patch('pylspci.command.lspci')
def test_hide_single_domain(self, lspci_mock):
def test_hide_single_domain(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder().hide_single_domain()
self.assertListEqual(list(builder), ['a', 'b'])
@ -222,7 +241,7 @@ class TestCommandBuilder(TestCase):
self.assertEqual(lspci_mock.call_args, call(hide_single_domain=True))
@patch('pylspci.command.lspci')
def test_with_ids(self, lspci_mock):
def test_with_ids(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder().with_ids(False)
self.assertListEqual(list(builder), ['a', 'b'])
@ -240,7 +259,7 @@ class TestCommandBuilder(TestCase):
))
@patch('pylspci.command.lspci')
def test_with_names(self, lspci_mock):
def test_with_names(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder().with_names(False)
self.assertListEqual(list(builder), ['a', 'b'])
@ -259,11 +278,13 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.SlotFilter')
@patch('pylspci.command.lspci')
def test_slot_filter_str(self, lspci_mock, filter_mock):
def test_slot_filter_str(self,
lspci_mock: MagicMock,
filter_mock: MagicMock) -> None:
with self.assertRaisesRegex(AssertionError, 'Only one positional'):
CommandBuilder().slot_filter('something', 'something else')
with self.assertRaisesRegex(AssertionError, 'Use either'):
CommandBuilder().slot_filter('something', domain='a')
CommandBuilder().slot_filter('something', domain=0xa)
filter_mock.parse.return_value = 'lefilter'
lspci_mock.return_value = ['a', 'b']
@ -276,33 +297,37 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.SlotFilter')
@patch('pylspci.command.lspci')
def test_slot_filter_kwargs(self, lspci_mock, filter_mock):
def test_slot_filter_kwargs(self,
lspci_mock: MagicMock,
filter_mock: MagicMock) -> None:
filter_mock.return_value = 'lefilter'
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder().slot_filter(
domain='a',
bus='b',
device='c',
function='d',
domain=0xa,
bus=0xb,
device=0xc,
function=0xd,
)
self.assertListEqual(list(builder), ['a', 'b'])
self.assertEqual(lspci_mock.call_count, 1)
self.assertEqual(lspci_mock.call_args, call(slot_filter='lefilter'))
self.assertEqual(filter_mock.call_count, 1)
self.assertEqual(filter_mock.call_args, call(
domain='a',
bus='b',
device='c',
function='d',
domain=0xa,
bus=0xb,
device=0xc,
function=0xd,
))
@patch('pylspci.command.DeviceFilter')
@patch('pylspci.command.lspci')
def test_device_filter_str(self, lspci_mock, filter_mock):
def test_device_filter_str(self,
lspci_mock: MagicMock,
filter_mock: MagicMock) -> None:
with self.assertRaisesRegex(AssertionError, 'Only one positional'):
CommandBuilder().device_filter('something', 'something else')
with self.assertRaisesRegex(AssertionError, 'Use either'):
CommandBuilder().device_filter('something', vendor='b')
CommandBuilder().device_filter('something', vendor=0xb)
filter_mock.parse.return_value = 'lefilter'
lspci_mock.return_value = ['a', 'b']
@ -315,25 +340,27 @@ class TestCommandBuilder(TestCase):
@patch('pylspci.command.DeviceFilter')
@patch('pylspci.command.lspci')
def test_device_filter_kwargs(self, lspci_mock, filter_mock):
def test_device_filter_kwargs(self,
lspci_mock: MagicMock,
filter_mock: MagicMock) -> None:
filter_mock.return_value = 'lefilter'
lspci_mock.return_value = ['a', 'b']
builder = CommandBuilder().device_filter(
cls='a',
vendor='b',
device='c',
cls=0xa,
vendor=0xb,
device=0xc,
)
self.assertListEqual(list(builder), ['a', 'b'])
self.assertEqual(lspci_mock.call_count, 1)
self.assertEqual(lspci_mock.call_args, call(device_filter='lefilter'))
self.assertEqual(filter_mock.call_count, 1)
self.assertEqual(filter_mock.call_args, call(
cls='a',
vendor='b',
device='c',
cls=0xa,
vendor=0xb,
device=0xc,
))
def test_with_default_parser(self):
def test_with_default_parser(self) -> None:
builder = CommandBuilder()
self.assertIsNone(builder._parser)
@ -344,7 +371,7 @@ class TestCommandBuilder(TestCase):
self.assertIsInstance(builder._parser, VerboseParser)
@patch('pylspci.command.lspci')
def test_with_parser(self, lspci_mock):
def test_with_parser(self, lspci_mock: MagicMock) -> None:
lspci_mock.return_value = ['a', 'b']
parser_mock = MagicMock(spec=SimpleParser)
parser_mock.parse.return_value = ('parsed_a', 'parsed_b')

View File

@ -1,8 +1,10 @@
from unittest import TestCase
from unittest.mock import patch, call, MagicMock
from unittest.mock import MagicMock, call, patch
from pylspci.command import (
IDResolveOption, list_access_methods, list_pcilib_params, lspci
)
from pylspci.fields import PCIAccessParameter
from pylspci.command import \
lspci, list_access_methods, list_pcilib_params, IDResolveOption
class TestCommand(TestCase):
@ -18,7 +20,9 @@ class TestCommand(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.subprocess.check_output')
def test_pciids(self, cmd_mock: MagicMock, is_file_mock: MagicMock):
def test_pciids(self,
cmd_mock: MagicMock,
is_file_mock: MagicMock) -> None:
cmd_mock.return_value = 'something'
is_file_mock.return_value = True
self.assertEqual(lspci(pciids='/somewhere'), 'something')
@ -30,7 +34,7 @@ class TestCommand(TestCase):
self.assertEqual(is_file_mock.call_count, 1)
@patch('pylspci.command.Path.is_file')
def test_pciids_missing(self, is_file_mock: MagicMock):
def test_pciids_missing(self, is_file_mock: MagicMock) -> None:
is_file_mock.return_value = False
with self.assertRaises(AssertionError):
lspci(pciids='/nowhere')
@ -38,7 +42,9 @@ class TestCommand(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.subprocess.check_output')
def test_pcimap(self, cmd_mock: MagicMock, is_file_mock: MagicMock):
def test_pcimap(self,
cmd_mock: MagicMock,
is_file_mock: MagicMock) -> None:
cmd_mock.return_value = 'something'
is_file_mock.return_value = True
self.assertEqual(lspci(pcimap='/somewhere'), 'something')
@ -50,7 +56,7 @@ class TestCommand(TestCase):
self.assertEqual(is_file_mock.call_count, 1)
@patch('pylspci.command.Path.is_file')
def test_pcimap_missing(self, is_file_mock: MagicMock):
def test_pcimap_missing(self, is_file_mock: MagicMock) -> None:
is_file_mock.return_value = False
with self.assertRaises(AssertionError):
lspci(pcimap='/nowhere')
@ -77,7 +83,7 @@ class TestCommand(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.subprocess.check_output')
def test_file(self, cmd_mock: MagicMock, is_file_mock: MagicMock):
def test_file(self, cmd_mock: MagicMock, is_file_mock: MagicMock) -> None:
cmd_mock.return_value = 'something'
is_file_mock.return_value = True
self.assertEqual(lspci(file='/somewhere'), 'something')
@ -89,7 +95,7 @@ class TestCommand(TestCase):
self.assertEqual(is_file_mock.call_count, 1)
@patch('pylspci.command.Path.is_file')
def test_file_missing(self, is_file_mock: MagicMock):
def test_file_missing(self, is_file_mock: MagicMock) -> None:
is_file_mock.return_value = False
with self.assertRaises(AssertionError):
lspci(file='/nowhere')
@ -189,7 +195,9 @@ class TestCommand(TestCase):
@patch('pylspci.command.Path.is_file')
@patch('pylspci.command.subprocess.check_output')
def test_everything(self, cmd_mock: MagicMock, is_file_mock: MagicMock):
def test_everything(self,
cmd_mock: MagicMock,
is_file_mock: MagicMock) -> None:
cmd_mock.return_value = 'something'
is_file_mock.return_value = True

View File

@ -1,12 +1,15 @@
from unittest import TestCase
from unittest.mock import patch, call, MagicMock
from typing import List
from unittest import TestCase
from unittest.mock import MagicMock, call, patch
from pylspci.device import Device
from pylspci.parsers import SimpleParser
class TestSimpleParser(TestCase):
parser: SimpleParser
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
@ -24,8 +27,10 @@ class TestSimpleParser(TestCase):
self.assertEqual(dev.vendor.name, 'Intel Corporation')
self.assertEqual(dev.device.id, 0x244e)
self.assertEqual(dev.device.name, '82801 PCI Bridge')
assert dev.subsystem_vendor is not None
self.assertEqual(dev.subsystem_vendor.id, 0x8086)
self.assertEqual(dev.subsystem_vendor.name, 'Intel Corporation')
assert dev.subsystem_device is not None
self.assertEqual(dev.subsystem_device.id, 0x244e)
self.assertEqual(dev.subsystem_device.name, '82801 PCI Bridge')
self.assertEqual(dev.revision, 0xd5)
@ -71,14 +76,16 @@ class TestSimpleParser(TestCase):
self.assertEqual(dev.vendor.name, '')
self.assertIsNone(dev.device.id)
self.assertEqual(dev.device.name, '')
assert dev.subsystem_vendor is not None
self.assertIsNone(dev.subsystem_vendor.id)
self.assertEqual(dev.subsystem_vendor.name, '')
assert dev.subsystem_device is not None
self.assertIsNone(dev.subsystem_device.id)
self.assertEqual(dev.subsystem_device.name, '')
self.assertIsNone(dev.revision)
self.assertIsNone(dev.progif)
@patch('pylspci.parsers.base.lspci')
@patch('pylspci.command.lspci')
def test_command(self, cmd_mock: MagicMock) -> None:
cmd_mock.return_value = \
'00:1c.3 "PCI bridge [0604]" "Intel Corporation [8086]" ' \
@ -92,3 +99,12 @@ class TestSimpleParser(TestCase):
self.assertEqual(cmd_mock.call_count, 1)
self.assertEqual(cmd_mock.call_args, call())
def test_verbose_error(self) -> None:
with self.assertRaises(ValueError) as ctx:
self.parser.run(verbose=True)
self.assertEqual(
ctx.exception.args[0],
'Verbose output is unsupported from the SimpleParser. '
'Please use the pylspci.parsers.VerboseParser instead.'
)

View File

@ -1,6 +1,7 @@
from unittest import TestCase
from unittest.mock import patch, call, MagicMock
from typing import List
from unittest import TestCase
from unittest.mock import MagicMock, call, patch
from pylspci.device import Device
from pylspci.parsers import VerboseParser
@ -16,11 +17,16 @@ ProgIf: 01
Driver: pcieport
Module: nouveau
Module: nvidia
""".strip()
NUMANode: 0
IOMMUGroup: 1
PhySlot: 4
"""
class TestVerboseParser(TestCase):
parser: VerboseParser
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
@ -38,14 +44,19 @@ class TestVerboseParser(TestCase):
self.assertEqual(dev.vendor.name, 'Intel Corporation')
self.assertEqual(dev.device.id, 0x244e)
self.assertEqual(dev.device.name, '82801 PCI Bridge')
assert dev.subsystem_vendor is not None
self.assertEqual(dev.subsystem_vendor.id, 0x8086)
self.assertEqual(dev.subsystem_vendor.name, 'Intel Corporation')
assert dev.subsystem_device is not None
self.assertEqual(dev.subsystem_device.id, 0x244e)
self.assertEqual(dev.subsystem_device.name, '82801 PCI Bridge')
self.assertEqual(dev.revision, 0xd5)
self.assertEqual(dev.progif, 0x01)
self.assertEqual(dev.driver, 'pcieport')
self.assertListEqual(dev.kernel_modules, ['nouveau', 'nvidia'])
self.assertEqual(dev.numa_node, 0)
self.assertEqual(dev.iommu_group, 1)
self.assertEqual(dev.physical_slot, '4')
def test_parse_str(self) -> None:
devices: List[Device] = self.parser.parse(SAMPLE_DEVICE)
@ -57,7 +68,7 @@ class TestVerboseParser(TestCase):
self.assertEqual(len(devices), 1)
self._check_device(devices[0])
@patch('pylspci.parsers.base.lspci')
@patch('pylspci.command.lspci')
def test_command(self, cmd_mock: MagicMock) -> None:
cmd_mock.return_value = '{0}\n\n{0}'.format(SAMPLE_DEVICE)
@ -69,3 +80,16 @@ class TestVerboseParser(TestCase):
self.assertEqual(cmd_mock.call_count, 1)
self.assertEqual(cmd_mock.call_args,
call(verbose=True, kernel_drivers=True))
def test_unknown_field(self) -> None:
with self.assertWarns(
UserWarning,
msg="Unsupported device field 'NewField' with value 'Value'\n"
"Please report this, along with the output of"
"`lspci -mmnnvvvk`, at "
"https://tildegit.org/lucidiot/pylspci/issues/new"):
devices: List[Device] = \
self.parser.parse(SAMPLE_DEVICE + 'NewField\tValue')
self.assertEqual(len(devices), 1)
self._check_device(devices[0])

View File

@ -0,0 +1,60 @@
from unittest import TestCase
from pylspci.device import Device
from pylspci.fields import NameWithID, Slot
class TestDevice(TestCase):
def test_as_dict(self) -> None:
d = Device(
slot=Slot('cafe:13:07.2'),
cls=NameWithID('Something [caf3]'),
vendor=NameWithID('Something [caf3]'),
device=NameWithID('Something [caf3]'),
subsystem_vendor=NameWithID('Something [caf3]'),
subsystem_device=NameWithID('Something [caf3]'),
revision=20,
progif=1,
driver='self_driving',
kernel_modules=['snd-pcsp'],
numa_node=0,
iommu_group=1,
physical_slot='4-2',
)
self.assertDictEqual(d.as_dict(), {
'slot': {
'bus': 0x13,
'device': 0x07,
'domain': 0xcafe,
'function': 0x2,
'parent': None
},
'cls': {
'id': 0xcaf3,
'name': 'Something'
},
'vendor': {
'id': 0xcaf3,
'name': 'Something'
},
'device': {
'id': 0xcaf3,
'name': 'Something'
},
'subsystem_vendor': {
'id': 0xcaf3,
'name': 'Something'
},
'subsystem_device': {
'id': 0xcaf3,
'name': 'Something'
},
'revision': 20,
'progif': 1,
'driver': 'self_driving',
'kernel_modules': ['snd-pcsp'],
'numa_node': 0,
'iommu_group': 1,
'physical_slot': '4-2',
})

View File

@ -1,5 +1,6 @@
from unittest import TestCase
from pylspci.fields import Slot, NameWithID, PCIAccessParameter
from pylspci.fields import NameWithID, PCIAccessParameter, Slot
class TestSlot(TestCase):
@ -60,16 +61,28 @@ class TestSlot(TestCase):
self.assertEqual(s.device, 0x06)
self.assertEqual(s.function, 0x6)
self.assertIsInstance(s.parent, Slot)
assert s.parent is not None # Required for type checks
self.assertEqual(s.parent.domain, 0xcafe)
self.assertEqual(s.parent.bus, 0xc0)
self.assertEqual(s.parent.device, 0x0f)
self.assertEqual(s.parent.function, 0x3)
self.assertIsInstance(s.parent.parent, Slot)
assert s.parent.parent is not None # Required for type checks
self.assertEqual(s.parent.parent.domain, 0xabcd)
self.assertEqual(s.parent.parent.bus, 0x13)
self.assertEqual(s.parent.parent.device, 0x07)
self.assertEqual(s.parent.parent.function, 0x2)
def test_as_dict(self) -> None:
s = Slot('cafe:13:07.2')
self.assertDictEqual(s.as_dict(), {
"domain": 0xcafe,
"bus": 0x13,
"device": 0x07,
"function": 0x2,
"parent": None,
})
class TestNameWithID(TestCase):
@ -125,6 +138,13 @@ class TestNameWithID(TestCase):
self.assertIsNone(n.id)
self.assertEqual(n.name, 'Something [hexa]')
def test_as_dict(self) -> None:
n = NameWithID('Something [caf3]')
self.assertDictEqual(n.as_dict(), {
"id": 0xcaf3,
"name": "Something",
})
class TestPCIAccessParameter(TestCase):
@ -156,3 +176,11 @@ class TestPCIAccessParameter(TestCase):
p2 = PCIAccessParameter('param.name Some description ()')
self.assertEqual(p1, p1)
self.assertNotEqual(p1, p2)
def test_as_dict(self) -> None:
p = PCIAccessParameter('param.name Some description (default value)')
self.assertDictEqual(p.as_dict(), {
"name": "param.name",
"description": "Some description",
"default": "default value",
})

View File

@ -1,10 +1,11 @@
from unittest import TestCase
from pylspci.filters import SlotFilter, DeviceFilter
from pylspci.filters import DeviceFilter, SlotFilter
class TestSlotFilter(TestCase):
def test_empty(self):
def test_empty(self) -> None:
f = SlotFilter()
self.assertIsNone(f.domain)
self.assertIsNone(f.bus)
@ -15,7 +16,7 @@ class TestSlotFilter(TestCase):
'SlotFilter(domain=None, bus=None, device=None, function=None)',
)
def test_str(self):
def test_str(self) -> None:
self.assertEqual(str(SlotFilter()), '::.')
self.assertEqual(str(SlotFilter(domain=0xcafe)), 'cafe::.')
self.assertEqual(
@ -23,7 +24,7 @@ class TestSlotFilter(TestCase):
'c0ff:e:e.7',
)
def test_parse(self):
def test_parse(self) -> None:
self.assertEqual(SlotFilter.parse(''), SlotFilter())
self.assertEqual(SlotFilter.parse('::.'), SlotFilter())
self.assertEqual(SlotFilter.parse('*:*:*.*'), SlotFilter())
@ -40,10 +41,24 @@ class TestSlotFilter(TestCase):
with self.assertRaises(ValueError):
SlotFilter.parse('g')
def test_eq(self) -> None:
self.assertEqual(
SlotFilter(domain=0xc0ff, bus=0xe, device=0xe, function=7),
SlotFilter(domain=0xc0ff, bus=0xe, device=0xe, function=7),
)
self.assertNotEqual(
SlotFilter(domain=0xc0ff, bus=0xf, device=0xe, function=7),
SlotFilter(domain=0xc0ff, bus=0xe, device=0xe, function=7),
)
self.assertNotEqual(
SlotFilter(domain=0xc0ff, bus=0xf, device=0xe, function=7),
'not a filter',
)
class TestDeviceFilter(TestCase):
def test_empty(self):
def test_empty(self) -> None:
f = DeviceFilter()
self.assertIsNone(f.vendor)
self.assertIsNone(f.device)
@ -53,7 +68,7 @@ class TestDeviceFilter(TestCase):
'DeviceFilter(cls=None, vendor=None, device=None)',
)
def test_str(self):
def test_str(self) -> None:
self.assertEqual(str(DeviceFilter()), '::')
self.assertEqual(str(DeviceFilter(vendor=0xcafe)), 'cafe::')
self.assertEqual(
@ -61,7 +76,7 @@ class TestDeviceFilter(TestCase):
'c0ff:e:e',
)
def test_parse(self):
def test_parse(self) -> None:
self.assertEqual(DeviceFilter.parse(''), DeviceFilter())
self.assertEqual(DeviceFilter.parse('::'), DeviceFilter())
self.assertEqual(DeviceFilter.parse('*:*:*'), DeviceFilter())
@ -78,3 +93,17 @@ class TestDeviceFilter(TestCase):
DeviceFilter.parse('4')
with self.assertRaises(ValueError):
DeviceFilter.parse('g')
def test_eq(self) -> None:
self.assertEqual(
DeviceFilter(vendor=0xc0ff, device=0xe, cls=0xe),
DeviceFilter(vendor=0xc0ff, device=0xe, cls=0xe),
)
self.assertNotEqual(
DeviceFilter(vendor=0xc0ff, device=0xf, cls=0xe),
DeviceFilter(vendor=0xc0ff, device=0xe, cls=0xe),
)
self.assertNotEqual(
DeviceFilter(vendor=0xc0ff, device=0xe, cls=0xe),
'not a filter',
)

View File

@ -1,5 +1,4 @@
flake8>=3.5
doc8>=0.8.0
Sphinx>=1.8.1
coverage>=4.5
codecov>=2.0
pre-commit>=2.9.2

View File

@ -1 +1 @@
cached-property>=1.5.1
cached-property>=1.5.1

View File

@ -1,5 +1,15 @@
[flake8]
exclude = .git,__pycache__,docs,*.pyc,venv
exclude=.git,__pycache__,docs,*.pyc,venv
[doc8]
ignore-path=**/*.txt,*.txt,*.egg-info,docs/_build,venv,.git
[mypy]
ignore_missing_imports=True
disallow_incomplete_defs=True
disallow_untyped_defs=True
check_untyped_defs=True
no_implicit_optional=True
[isort]
multi_line_output=5

View File

@ -1,7 +1,8 @@
#!/usr/bin/env python3
from setuptools import setup, find_packages
from typing import List
from setuptools import find_packages, setup
def read_requirements(filename: str) -> List[str]:
return [req.strip() for req in open(filename)]
@ -21,21 +22,27 @@ setup(
'console_scripts': ['pylspci=pylspci.__main__:main'],
},
package_data={
'': ['*.md', 'LICENSE', 'README'],
'': [
'VERSION',
'LICENSE',
'README.rst',
'requirements.txt',
'requirements-dev.txt',
],
'pylspci': ['py.typed'],
},
python_requires='>=3.5',
python_requires='>=3.6',
install_requires=requirements,
extras_require={
'dev': dev_requirements,
},
tests_require=dev_requirements,
test_suite='pylspci.tests',
license='GNU General Public License 3',
description="Simple parser for lspci -mmnn.",
long_description=open('README.rst').read(),
long_description_content_type='text/x-rst',
keywords="lspci parser",
url="https://gitlab.com/Lucidiot/pylspci",
url="https://tildegit.org/lucidiot/pylspci",
classifiers=[
"Development Status :: 4 - Beta",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
@ -43,16 +50,21 @@ setup(
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Software Development :: Libraries",
"Topic :: System :: Hardware",
"Topic :: Utilities",
"Typing :: Typed",
],
project_urls={
"Source Code": "https://gitlab.com/Lucidiot/pylspci",
"GitHub Mirror": "https://github.com/Lucidiot/pylspci",
"Homepage": "https://tildegit.org/lucidiot/pylspci",
"Changelog": "https://tildegit.org/lucidiot/pylspci/releases",
"Documentation": "https://lucidiot.tildepages.org/pylspci/",
"Issue tracker": "https://tildegit.org/lucidiot/pylspci/issues",
}
)