summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2021-03-01 19:19:57 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2021-03-02 21:52:49 +0000
commitbd826e60aa92f12a4a2a619996f4b9b3842e2c43 (patch)
tree125fb4db903714195917dc116e5a0652403b4ebf
parentInitial commit. (diff)
downloadpingtop-bd826e60aa92f12a4a2a619996f4b9b3842e2c43.tar.xz
pingtop-bd826e60aa92f12a4a2a619996f4b9b3842e2c43.zip
Adding upstream version 0.3.0.upstream/0.3.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.bumpversion.cfg7
-rw-r--r--.circleci/config.yml29
-rw-r--r--.gitignore123
-rw-r--r--LICENSE21
-rw-r--r--MANIFEST.in1
-rw-r--r--README.rst88
-rw-r--r--pingtop/__init__.py459
-rw-r--r--pingtop/ping.py283
-rw-r--r--setup.py30
-rw-r--r--tests/test_input.py9
-rw-r--r--tests/test_runnable.py3
-rw-r--r--tox.ini10
12 files changed, 1063 insertions, 0 deletions
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
new file mode 100644
index 0000000..409b61b
--- /dev/null
+++ b/.bumpversion.cfg
@@ -0,0 +1,7 @@
+[bumpversion]
+current_version = 0.3.0
+commit = True
+tag = True
+
+[bumpversion:file:setup.py]
+
diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..d6e1673
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,29 @@
+version: 2
+jobs:
+ python:
+ docker:
+ - image: circleci/python:3.7
+
+ steps:
+ - checkout
+ - run:
+ name: Install Tox
+ command: |
+ pip install --user tox
+ echo 'export PATH="$PATH":"$HOME"/.local/bin' >> $BASH_ENV
+ source $BASH_ENV
+ - run:
+ name: Run Tests
+ environment:
+ TEST_RESULTS_DIR: /tmp/tox/
+ command: |
+ mkdir /tmp/tox
+ tox
+ - store_test_results:
+ path: /tmp/tox
+
+workflows:
+ version: 2
+ UnitTests:
+ jobs:
+ - python
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..38ce427
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,123 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don’t work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3c0c064
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 赖信涛
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..84c71df
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1 @@
+include readme.md \ No newline at end of file
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..4f78e03
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,88 @@
+pingtop
+=======
+
+|CircleCI|
+
+Ping multiple servers and show the result in a top like terminal UI.
+
+|asciicast|
+
+Install
+-------
+
+::
+
+ pip install pingtop
+
+Usage
+-----
+
+Then ping mutiple server:
+
+::
+
+ pingtop baidu.com google.com twitter.com
+
+This project is using
+`click <https://click.palletsprojects.com/en/7.x/>`__. Check help info
+with ``pingtop -h``.
+
+::
+
+ ~ pingtop --help
+ Usage: pingtop [OPTIONS] [HOST]...
+
+ Options:
+ -s, --packetsize INTEGER specify the number of data bytes to be sent.
+ The default is 56, which translates into 64
+ ICMP data bytes when combined with the 8
+ bytes of ICMP header data. This option
+ cannot be used with ping sweeps. [default:
+ 56]
+ -l, --logto PATH
+ -v, --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL]
+ --help Show this message and exit.
+
+Why do I get ``Permission denied`` ?
+------------------------------------
+
+We use ICMP socket to send ping packet without ``sudo`` (See `this
+post <https://blog.lilydjwg.me/2013/10/29/non-privileged-icmp-ping.41390.html>`__
+by lilydjwg(in Chinese)), however, who(which group) can use this feature
+is controled by a kernel parameter: ``net.ipv4.ping_group_range``.
+
+::
+
+ cat /proc/sys/net/ipv4/ping_group_range
+
+ 1 0
+
+The default value is ``1 0``, this means the whose group number from 1
+to 0 can use this feature(which means nobody can use this), so you get a
+Permission denied .
+
+To fix this, change this variable to a proper range include your group
+id, like this:
+
+::
+
+ [vagrant@centos7 pingtop]$ id
+ uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
+
+ [vagrant@centos7 pingtop]$ sudo sysctl -w net.ipv4.ping_group_range='0 1001'
+ net.ipv4.ping_group_range = 0 1001
+
+Credits
+-------
+
+- For the credits of ping.py’s implementation please refer
+ `ping.py <./ping.py>`__.
+- The UI was built on `panwid <https://github.com/tonycpsu/panwid>`__
+ thanks to @tonycpsu.
+- @\ `gzxultra <https://github.com/gzxultra>`__ helped to solve the
+ permission issues.
+
+.. |CircleCI| image:: https://circleci.com/gh/laixintao/pingtop.svg?style=svg
+ :target: https://circleci.com/gh/laixintao/pingtop
+.. |asciicast| image:: https://asciinema.org/a/onbBCmHzhltau7iqButUGx6yu.svg
+ :target: https://asciinema.org/a/onbBCmHzhltau7iqButUGx6yu
diff --git a/pingtop/__init__.py b/pingtop/__init__.py
new file mode 100644
index 0000000..fd778a9
--- /dev/null
+++ b/pingtop/__init__.py
@@ -0,0 +1,459 @@
+# -*- coding: utf-8 -*-
+
+import logging
+import click
+import urwid
+import threading
+import socket
+from concurrent.futures import ThreadPoolExecutor
+from .ping import do_one
+import time
+import statistics
+
+from panwid.datatable import DataTableColumn, DataTable
+from urwid_utils.palette import PaletteEntry, Palette
+
+logger = logging.getLogger(__name__)
+
+WAIT_TIME = 1 # seconds
+SOCKET_TIMEOUT = 1
+hosts = {}
+event = threading.Event()
+screen_lock = threading.Lock()
+sort_keys = {
+ "H": "host",
+ "S": "seq",
+ "R": "real_rtt",
+ "I": "min_rtt",
+ "A": "avg_rtt",
+ "M": "max_rtt",
+ "T": "std",
+ "L": "lost",
+}
+current_sort_column = "real_rtt"
+sort_reverse = False
+UNICODE_BLOCKS = "▁▂▃▄▅▆▇█"
+
+
+screen = urwid.raw_display.Screen()
+screen.set_terminal_properties(256)
+
+NORMAL_FG_MONO = "white"
+NORMAL_FG_16 = "light gray"
+NORMAL_BG_16 = "black"
+NORMAL_FG_256 = "light gray"
+NORMAL_BG_256 = "g0"
+
+COLUMNS = [
+ DataTableColumn(
+ "host",
+ label="Host(IP)",
+ width=16,
+ align="left",
+ sort_key=lambda v: (v is None, v),
+ attr="color",
+ padding=1,
+ ),
+ DataTableColumn(
+ "ip",
+ label="IP",
+ width=3 * 4 + 3 + 2,
+ align="left",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=1,
+ ),
+ DataTableColumn(
+ "seq",
+ label="Seq",
+ width=4,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "real_rtt",
+ label="RTT",
+ width=6,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "min_rtt",
+ label="Min",
+ width=6,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "avg_rtt",
+ label="Avg",
+ width=8,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "max_rtt",
+ label="Max",
+ width=6,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "std",
+ label="Std",
+ width=8,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "lost",
+ label="LOSS",
+ width=5,
+ align="right",
+ sort_reverse=True,
+ sort_icon=False,
+ padding=0,
+ ),
+ DataTableColumn(
+ "lostp", label="LOSS%", width=6, align="right", sort_icon=False, padding=0
+ ),
+ DataTableColumn("stat", label="Stat", align="left", sort_icon=False, padding=0),
+]
+
+
+def get_last_column_width():
+ screen_width = screen.get_cols_rows()[0]
+ previous_all_column_width = sum(col.width_with_padding() for col in COLUMNS)
+ last_column_width = screen_width - previous_all_column_width - 10
+ logger.info(f"Get last_column_width = {last_column_width}.")
+ return last_column_width
+
+
+def get_palette():
+ attr_entries = {}
+ for attr in ["dark red", "dark green", "dark blue"]:
+ attr_entries[attr.split()[1]] = PaletteEntry(
+ mono="white", foreground=attr, background="black"
+ )
+ entries = DataTable.get_palette_entries(user_entries=attr_entries)
+ palette = Palette("default", **entries)
+ return palette
+
+
+def rerender_table(loop, table):
+ """
+ Rerender table box from its data, and make loop redraw screen.
+ Not thread safe.
+ """
+ # save focused host
+ position = table.focus_position
+ focus_host = ""
+ try:
+ row = table.get_row_by_position(position)
+ except IndexError:
+ pass
+ else:
+ focus_host = row.values["host"]
+
+ # restore sort column
+ table.reset(reset_sort=True)
+ table.sort_by_column(current_sort_column, sort_reverse)
+
+ # restore focused host
+ for r in table.filtered_rows:
+ row = table.get_row_by_position(r)
+ if row.values["host"] == focus_host:
+ table.set_focus(r)
+ break
+ loop.draw_screen()
+
+
+class PingDataTable(DataTable):
+
+ columns = COLUMNS[:]
+
+ index = "index"
+
+ def __init__(self, num_rows=10, *args, **kwargs):
+ self.num_rows = num_rows
+ self.query_data = self.query()
+ self.last_rec = len(self.query_data)
+ super().__init__(*args, **kwargs)
+
+ def query(self, sort=(None, None), offset=None, limit=None, load_all=False):
+ global hosts
+ rows = []
+ for host, properties in hosts.items():
+ temp = {"host": host}
+ temp.update(properties)
+ rows.append(temp)
+ return rows
+
+ def query_result_count(self):
+ return len(self.query_data)
+
+
+class MainBox(urwid.WidgetWrap):
+ def __init__(self, packetsize, *args, **kwargs):
+ self.table = PingDataTable(*args, **kwargs)
+ urwid.connect_signal(
+ self.table,
+ "select",
+ lambda source, selection: logger.info("selection: %s" % (selection)),
+ )
+ banner = urwid.Text("Pingtop", align="center")
+ key_label = "[Sort Key] {}".format(
+ " ".join("{}: {}".format(key, col) for key, col in sort_keys.items())
+ )
+ quit_key_label = "[Quit key] Q"
+ packet_size_line = f"Sending ICMP packet with {packetsize} data bytes."
+ self.pile = urwid.Pile(
+ [
+ ("pack", banner),
+ ("pack", urwid.Text(packet_size_line)),
+ ("pack", urwid.Text(key_label)),
+ ("pack", urwid.Text(quit_key_label)),
+ ("pack", urwid.Divider("\N{HORIZONTAL BAR}")),
+ ("weight", 1, self.table),
+ ]
+ )
+ super().__init__(self.pile)
+
+
+def global_input(key):
+ global current_sort_column
+ global sort_reverse
+
+ # keyboard input only
+ logger.info(f"[KEY]: {key}")
+ if not isinstance(key, str):
+ return
+
+ if key in ("q", "Q", "^C"):
+ event.clear()
+ raise urwid.ExitMainLoop()
+ elif key.upper() in sort_keys:
+ upper_key = key.upper()
+ sort_column = sort_keys[upper_key]
+ if current_sort_column == sort_column:
+ sort_reverse = not sort_reverse
+ else:
+ sort_reverse = False
+ current_sort_column = sort_column
+ else:
+ return False
+
+
+def forever_ping(dest, index_flag, packetsize, tablebox, mainloop):
+ global hosts
+ global event
+ last_column_width = get_last_column_width()
+ try:
+ dest_ip = socket.gethostbyname(dest)
+ except socket.gaierror as e:
+ hosts[dest]["error"] = e
+ hosts[dest]["ip"] = "Unknown"
+ with event.is_set() and screen_lock:
+ rerender_table(mainloop, tablebox.table)
+ return
+
+ dest_attr = hosts[dest]
+
+ dest_attr["ip"] = dest_ip
+ dest_attr.setdefault("lost", 0)
+ dest_attr.setdefault("lostp", "0%")
+ dest_attr.setdefault("seq", 0)
+ dest_attr.setdefault("real_rtt", SOCKET_TIMEOUT * 1000)
+ dest_attr.setdefault("min_rtt", SOCKET_TIMEOUT * 1000)
+ dest_attr.setdefault("max_rtt", SOCKET_TIMEOUT * 1000)
+ dest_attr.setdefault("avg_rtt", SOCKET_TIMEOUT * 1000)
+ dest_attr.setdefault("std", 0)
+ dest_attr.setdefault("stat", "")
+ rtts = dest_attr.setdefault("rtts", [])
+
+ while event.is_set():
+ logging.info(f"ping {dest}, {index_flag}")
+ delay = do_one(dest, SOCKET_TIMEOUT, packetsize, index_flag)
+ logging.info(f"[Done]ping {dest}, {index_flag} rtt={delay}")
+ with screen_lock:
+ dest_attr["seq"] += 1
+ if delay is None:
+ dest_attr["lost"] += 1
+ dest_attr["lostp"] = "{0:.0%}".format(
+ dest_attr["lost"] / dest_attr["seq"]
+ )
+ block_mark = " "
+ sleep_before_next_ping = WAIT_TIME
+ else:
+ delay_ms = int(delay * 1000)
+ rtts.append(delay_ms)
+ dest_attr["real_rtt"] = delay_ms
+ dest_attr["min_rtt"] = min(dest_attr["rtts"])
+ dest_attr["max_rtt"] = max(dest_attr["rtts"])
+ dest_attr["avg_rtt"] = sum(dest_attr["rtts"]) / dest_attr["seq"]
+ if len(rtts) >= 2:
+ dest_attr["std"] = float("%2.1f" % (statistics.stdev(rtts)))
+
+ block_mark = UNICODE_BLOCKS[min(delay_ms // 30, 7)]
+ sleep_before_next_ping = WAIT_TIME - delay
+ dest_attr["stat"] = (dest_attr["stat"] + block_mark)[-last_column_width:]
+
+ try:
+ rerender_table(mainloop, tablebox.table)
+ except AssertionError:
+ break
+ logger.info(f"{dest}({dest_ip})Sleep for seconds {sleep_before_next_ping}")
+ time.sleep(max(0, sleep_before_next_ping))
+
+
+def _raise_error(future):
+ exp = future.exception()
+ if exp:
+ logging.exception(exp)
+
+
+PACKETSIZE_HELP = "specify the number of data bytes to be sent. The default is 56, which translates into 64 ICMP data bytes when combined with the 8 bytes of ICMP header data. This option cannot be used with ping sweeps."
+
+
+def config_logger(level, logfile):
+ global logger
+ _level = {
+ "DEBUG": logging.DEBUG,
+ "INFO": logging.INFO,
+ "WARNING": logging.WARNING,
+ "ERROR": logging.ERROR,
+ "CRITICAL": logging.CRITICAL,
+ }[level]
+ logging.basicConfig(
+ filename=logfile,
+ filemode="a",
+ format="%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s",
+ datefmt="%H:%M:%S",
+ level=_level,
+ )
+ logger = logging.getLogger(__name__)
+ return logger
+
+
+def ping_statistics(data):
+ """
+ Render result statistics
+ :return: str result string
+ """
+ TEMPLATE = """--- {hostname} ping statistics ---
+{packet} packets transmitted, {packet_received} packets received, {packet_lost:.1f}% packet loss"""
+ RTT_TEMPLATE = """\nround-trip min/avg/max/stddev = {min:3.2f}/{avg:3.2f}/{max:3.2f}/{stddev:3.2f} ms"""
+ ERROR_TEMPLATE = """--- {hostname} ping statistics ---
+ping: cannot resolve {hostname}: Unknown host"""
+ results = []
+ for hostname, value in data.items():
+ if value.get("error"):
+ # I could use PEP572 here
+ results.append(ERROR_TEMPLATE.format(hostname=hostname))
+ continue
+ rtts = value["rtts"]
+ if value["seq"] == 0:
+ packet, packet_received, packet_lost = 0, 0, 0
+ else:
+ packet = value["seq"]
+ packet_received = int(value["seq"]) - int(value["lost"])
+ packet_lost = value["lost"] / value["seq"] * 100
+
+ packets_info = TEMPLATE.format(
+ hostname=hostname,
+ packet=packet,
+ packet_received=packet_received,
+ packet_lost=packet_lost,
+ )
+ rtt_info = ""
+ if rtts:
+ stdev = 0
+ if len(rtts) > 2:
+ stdev = statistics.stdev(value["rtts"])
+ rtt_info = RTT_TEMPLATE.format(
+ min=min(value["rtts"]),
+ avg=sum(value["rtts"]) / value["seq"],
+ max=max(value["rtts"]),
+ stddev=stdev,
+ )
+ results.append(packets_info + rtt_info)
+ return "\n".join(results)
+
+
+@click.command()
+@click.argument("host", nargs=-1)
+@click.option(
+ "--packetsize", "-s", type=int, default=56, show_default=True, help=PACKETSIZE_HELP
+)
+@click.option("--logto", "-l", type=click.Path(), default=None)
+@click.option(
+ "--log-level",
+ "-v",
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
+ default="DEBUG",
+)
+@click.option(
+ "--summary/--no-summary",
+ default=True,
+ help="Weather to print BSD compatible summary.",
+)
+def multi_ping(host, packetsize, logto, log_level, summary):
+ global hosts
+ if logto:
+ config_logger(log_level, logto)
+ hosts = {h: {} for h in host}
+ logger.info(f"Hosts: {hosts}")
+ hosts_num = len(hosts)
+
+ if (hosts_num) == 0:
+ raise click.BadParameter("Hosts were not specified.")
+
+ # update the HOST column width to fit max length host
+ max_host_length = max([len(host) for host in hosts] + [9]) + 2
+ COLUMNS[0].width = max_host_length if max_host_length < 40 else 40
+ # start the UI loop
+ tablebox = MainBox(
+ packetsize,
+ 1000,
+ index="uniqueid",
+ sort_refocus=True,
+ sort_icons=True,
+ with_scrollbar=True,
+ border=(1, "\N{VERTICAL LINE}", "blue"),
+ padding=3,
+ with_footer=False,
+ ui_sort=False,
+ )
+ mainloop = urwid.MainLoop(
+ tablebox, palette=get_palette(), screen=screen, unhandled_input=global_input
+ )
+
+ # open threadpool to ping
+ logger.info(f"Open ThreadPoolExecutor with max_workers={hosts_num}.")
+ pool = ThreadPoolExecutor(max_workers=hosts_num)
+ event.set()
+ for index, host in zip(range(len(hosts)), hosts):
+ future = pool.submit(forever_ping, host, index, packetsize, tablebox, mainloop)
+ future.add_done_callback(_raise_error)
+
+ # Go!
+ mainloop.run()
+
+ if summary:
+ click.echo(ping_statistics(hosts))
+
+
+if __name__ == "__main__":
+ multi_ping()
diff --git a/pingtop/ping.py b/pingtop/ping.py
new file mode 100644
index 0000000..b16fe80
--- /dev/null
+++ b/pingtop/ping.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python
+
+"""
+ A pure python ping implementation using raw socket.
+
+
+ Note that ICMP messages can only be sent from processes running as root.
+
+
+ Derived from ping.c distributed in Linux's netkit. That code is
+ copyright (c) 1989 by The Regents of the University of California.
+ That code is in turn derived from code written by Mike Muuss of the
+ US Army Ballistic Research Laboratory in December, 1983 and
+ placed in the public domain. They have my thanks.
+
+ Bugs are naturally mine. I'd be glad to hear about them. There are
+ certainly word - size dependenceies here.
+
+ Copyright (c) Matthew Dixon Cowles, <http://www.visi.com/~mdc/>.
+ Distributable under the terms of the GNU General Public License
+ version 2. Provided with no warranties of any sort.
+
+ Original Version from Matthew Dixon Cowles:
+ -> ftp://ftp.visi.com/users/mdc/ping.py
+
+ Rewrite by Jens Diemer:
+ -> http://www.python-forum.de/post-69122.html#69122
+
+ Rewrite by George Notaras:
+ -> http://www.g-loaded.eu/2009/10/30/python-ping/
+
+ Fork by Pierre Bourdon:
+ -> http://bitbucket.org/delroth/python-ping/
+
+ Revision history
+ ~~~~~~~~~~~~~~~~
+
+ November 22, 1997
+ -----------------
+ Initial hack. Doesn't do much, but rather than try to guess
+ what features I (or others) will want in the future, I've only
+ put in what I need now.
+
+ December 16, 1997
+ -----------------
+ For some reason, the checksum bytes are in the wrong order when
+ this is run under Solaris 2.X for SPARC but it works right under
+ Linux x86. Since I don't know just what's wrong, I'll swap the
+ bytes always and then do an htons().
+
+ December 4, 2000
+ ----------------
+ Changed the struct.pack() calls to pack the checksum and ID as
+ unsigned. My thanks to Jerome Poincheval for the fix.
+
+ May 30, 2007
+ ------------
+ little rewrite by Jens Diemer:
+ - change socket asterisk import to a normal import
+ - replace time.time() with time.clock()
+ - delete "return None" (or change to "return" only)
+ - in checksum() rename "str" to "source_string"
+
+ November 8, 2009
+ ----------------
+ Improved compatibility with GNU/Linux systems.
+
+ Fixes by:
+ * George Notaras -- http://www.g-loaded.eu
+ Reported by:
+ * Chris Hallman -- http://cdhallman.blogspot.com
+
+ Changes in this release:
+ - Re-use time.time() instead of time.clock(). The 2007 implementation
+ worked only under Microsoft Windows. Failed on GNU/Linux.
+ time.clock() behaves differently under the two OSes[1].
+
+ [1] http://docs.python.org/library/time.html#time.clock
+
+ September 25, 2010
+ ------------------
+ Little modifications by Georgi Kolev:
+ - Added quiet_ping function.
+ - returns percent lost packages, max round trip time, avrg round trip
+ time
+ - Added packet size to verbose_ping & quiet_ping functions.
+ - Bump up version to 0.2
+
+ April, 2019
+ -----------
+ Forked by laixintao:
+ - Migrate to Python3
+ - Make it thread safe by setting a flag in packet
+ - do not need sudo (by @gzxultra (Zhixiang) )
+"""
+
+__version__ = "0.2"
+
+import os
+import select
+import socket
+import struct
+import sys
+import time
+
+# From /usr/include/linux/icmp.h; your milage may vary.
+ICMP_ECHO_REQUEST = 8 # Seems to be the same on Solaris.
+
+
+def checksum(source_string):
+ """
+ I'm not too confident that this is right but testing seems
+ to suggest that it gives the same answers as in_cksum in ping.c
+ """
+ sum = 0
+ count_to = int((len(source_string) / 2) * 2)
+ for count in range(0, count_to, 2):
+ this = source_string[count + 1] * 256 + source_string[count]
+ sum = sum + this
+ sum = sum & 0xffffffff # Necessary?
+
+ if count_to < len(source_string):
+ sum = sum + ord(source_string[len(source_string) - 1])
+ sum = sum & 0xffffffff # Necessary?
+
+ sum = (sum >> 16) + (sum & 0xffff)
+ sum = sum + (sum >> 16)
+ answer = ~sum
+ answer = answer & 0xffff
+
+ # Swap bytes. Bugger me if I know why.
+ answer = answer >> 8 | (answer << 8 & 0xff00)
+
+ return answer
+
+
+def receive_one_ping(my_socket, id, timeout):
+ """
+ Receive the ping from the socket.
+ """
+ time_left = timeout
+ while True:
+ started_select = time.time()
+ what_ready = select.select([my_socket], [], [], time_left)
+ how_long_in_select = time.time() - started_select
+ if what_ready[0] == []: # Timeout
+ return
+
+ time_received = time.time()
+ received_packet, addr = my_socket.recvfrom(1024)
+ icmpHeader = received_packet[20:28]
+ type, code, checksum, packet_id, sequence = struct.unpack("bbHHh", icmpHeader)
+ if packet_id == id:
+ bytes = struct.calcsize("d")
+ time_sent = struct.unpack("d", received_packet[28 : 28 + bytes])[0]
+ return time_received - time_sent
+
+ time_left = time_left - how_long_in_select
+ if time_left <= 0:
+ return
+
+
+def send_one_ping(my_socket, dest_addr, id, psize):
+ """
+ Send one ping to the given >dest_addr<.
+ """
+ dest_addr = socket.gethostbyname(dest_addr)
+
+ # Remove header size from packet size
+ # psize = psize - 8
+ # laixintao edit:
+ # Do not need to remove header here. From BSD ping man:
+ # The default is 56, which translates into 64 ICMP data
+ # bytes when combined with the 8 bytes of ICMP header data.
+
+ # Header is type (8), code (8), checksum (16), id (16), sequence (16)
+ my_checksum = 0
+
+ # Make a dummy heder with a 0 checksum.
+ header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, my_checksum, id, 1)
+ bytes = struct.calcsize("d")
+ data = (psize - bytes) * b"Q"
+ data = struct.pack("d", time.time()) + data
+
+ # Calculate the checksum on the data and the dummy header.
+ my_checksum = checksum(header + data)
+
+ # Now that we have the right checksum, we put that in. It's just easier
+ # to make up a new header than to stuff it into the dummy.
+ header = struct.pack(
+ "bbHHh", ICMP_ECHO_REQUEST, 0, socket.htons(my_checksum), id, 1
+ )
+ packet = header + data
+ my_socket.sendto(packet, (dest_addr, 1)) # Don't know about the 1
+
+
+def do_one(dest_addr, timeout, psize, flag=0):
+ """
+ Returns either the delay (in seconds) or none on timeout.
+ """
+ icmp = socket.getprotobyname("icmp")
+ try:
+ if os.getuid() != 0:
+ my_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, icmp)
+ else:
+ my_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp)
+ except socket.error as e:
+ if e.errno == 1:
+ # Operation not permitted
+ msg = str(e)
+ raise socket.error(msg)
+ raise # raise the original error
+
+ process_pre = os.getpid() & 0xFF00
+ flag = flag & 0x00FF
+ my_id = process_pre | flag
+
+ send_one_ping(my_socket, dest_addr, my_id, psize)
+ delay = receive_one_ping(my_socket, my_id, timeout)
+
+ my_socket.close()
+ return delay
+
+
+def verbose_ping(dest_addr, timeout=2, count=4, psize=64):
+ """
+ Send `count' ping with `psize' size to `dest_addr' with
+ the given `timeout' and display the result.
+ """
+ for i in range(count):
+ print("ping %s with ..." % dest_addr, end="")
+ try:
+ delay = do_one(dest_addr, timeout, psize)
+ except socket.gaierror as e:
+ print("failed. (socket error: '%s')" % e[1])
+ break
+
+ if delay == None:
+ print("failed. (timeout within %ssec.)" % timeout)
+ else:
+ delay = delay * 1000
+ print("get ping in %0.4fms" % delay)
+ print()
+
+
+def quiet_ping(dest_addr, timeout=2, count=4, psize=64):
+ """
+ Send `count' ping with `psize' size to `dest_addr' with
+ the given `timeout' and display the result.
+ Returns `percent' lost packages, `max' round trip time
+ and `avrg' round trip time.
+ """
+ mrtt = None
+ artt = None
+ lost = 0
+ plist = []
+
+ for i in range(count):
+ try:
+ delay = do_one(dest_addr, timeout, psize)
+ except socket.gaierror as e:
+ print("failed. (socket error: '%s')" % e[1])
+ break
+
+ if delay != None:
+ delay = delay * 1000
+ plist.append(delay)
+
+ # Find lost package percent
+ percent_lost = 100 - (len(plist) * 100 / count)
+
+ # Find max and avg round trip time
+ if plist:
+ mrtt = max(plist)
+ artt = sum(plist) / len(plist)
+
+ return percent_lost, mrtt, artt
+
+
+if __name__ == "__main__":
+ print(do_one("google.com", 1, 64))
+ print(do_one("baidu.com", 1, 64))
+ verbose_ping("heise.de")
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..26e1dcc
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+
+from setuptools import setup, find_packages
+from os import path
+
+this_directory = path.abspath(path.dirname(__file__))
+with open(path.join(this_directory, "README.rst")) as f:
+ long_description = f.read()
+
+setup(
+ name="pingtop",
+ version="0.3.0",
+ packages=find_packages(),
+ description="Ping multiple servers and show the result in a top like terminal UI.",
+ author="laixintao",
+ author_email="laixintaoo@gmail.com",
+ url="https://github.com/laixintao/pingtop",
+ entry_points={"console_scripts": ["pingtop=pingtop:multi_ping"]},
+ install_requires=["panwid", "click"],
+ classifiers=[
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.7",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ ],
+ keywords=["IP", "ping", "icmp"],
+ long_description=long_description,
+ long_description_content_type="text/x-rst",
+)
diff --git a/tests/test_input.py b/tests/test_input.py
new file mode 100644
index 0000000..c2bc2b8
--- /dev/null
+++ b/tests/test_input.py
@@ -0,0 +1,9 @@
+import pytest
+import urwid
+from pingtop import global_input
+
+
+def test_global_input():
+ global current_sort_column
+ with pytest.raises(urwid.main_loop.ExitMainLoop):
+ global_input("q")
diff --git a/tests/test_runnable.py b/tests/test_runnable.py
new file mode 100644
index 0000000..d17e795
--- /dev/null
+++ b/tests/test_runnable.py
@@ -0,0 +1,3 @@
+def test_hello():
+ import pingtop
+ return True
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..4e493f4
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,10 @@
+[tox]
+envlist=py{37}
+
+[testenv]
+deps=
+ pytest
+ pytest-cov
+
+commands=
+ py.test tests --cov=pingtop --cov=ping --junitxml={env:TEST_RESULTS_DIR:.tox/}tox-{envname}.xml {posargs}