Repository: equinor/weblog

Issue:
Make weblog Python 3.7 compatible
Due to `async` being a keyword from Python 3.7, we need to change the signature of `log`.

In order to make it backwards compatible, we could change the current:

```python
    def log(application, event, user=None, extra_info=None, async=False, url=None):
```

into 

```python
    def log(application, event, user=None, extra_info=None, url=None, async_=False, **kwargs):
        if async_ or 'async' in kwargs and kwargs['async']:
            LogAsync(application, event, user=user, extra_info=extra_info, url=url)
        else:
            LogSync(application, event, user=user, extra_info=extra_info, url=url)
```

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..ef37f15
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,10 @@
+language: python
+python:
+  - 2.7
+  - 3.6
+
+before_script:
+  - pip install -e .
+
+script:
+  - pytest
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ad22d9f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015---2019 Equinor ASA
+
+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/README.md b/README.md
new file mode 100644
index 0000000..a4c1496
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# web_log [![Build Status](https://travis-ci.org/equinor/weblog.svg?branch=master)](https://travis-ci.org/equinor/weblog)
+
+The `web_log` (sic) module is a minimal logging module for logging
+events to a server, in our case, a
+[logstash](https://github.com/elastic/logstash) server.
+
+To install it, the variables in `web_log.constants` need to be updated
+according to your needs.
+
+From a user perspective, either use the `wlog` executable, which takes
+arguments `application`, `event`, and `extra`, e.g.
+
+```bash
+wlog MyApplication crash some_stacktrace_or_message
+```
+
+From an API perspective, one can use it with
+
+```python
+import web_log
+web_log.log('MyApplication',
+            'crash',
+            'some_extra_stacktrace_or_message',
+            async_=True)  # or False
+```
diff --git a/bin/wlog b/bin/wlog
deleted file mode 100644
index 1ffa7b5..0000000
--- a/bin/wlog
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env python
-"""wlog is a lightweight thin logger that logs against a logger service.
-
-"""
-
-import sys
-import web_log
-
-def main(args):
-    if len(args) > 1 and args[1] == '-v':
-        print('wlog %s' % web_log.WEBLOG_VERSION)
-        exit(0)
-    if len(args) < 3:
-        exit('Usage: wlog application event [extra]\n       wlog -v')
-    extra = ''
-    application = args[1]
-    event = args[2]
-    extra = ' '.join(args[3:])
-    web_log.log(application, event, extra_info=extra)
-
-
-if __name__ == '__main__':
-    main(sys.argv)
diff --git a/setup.py b/setup.py
index ec8df18..4c322eb 100755
--- a/setup.py
+++ b/setup.py
@@ -3,12 +3,13 @@
 from setuptools import setup
 
 setup(
-    name='weblog',
-    version='1.0',
-    packages=['web_log'],
-    author='Software Innovation, Statoil ASA',
-    author_email='fg_gpl@statoil.com',
+    name="weblog",
+    version="1.1.0",
+    packages=["web_log"],
+    author="Software Innovation, Equinor ASA",
+    author_email="fg_gpl@equinor.com",
+    license="MIT",
     description="Logs json logs to somewhere",
-    scripts=['bin/wlog'],
-    install_requires=["requests"]
+    entry_points={"console_scripts": ["wlog=web_log:wlog"]},
+    install_requires=["requests"],
 )
diff --git a/web_log/__init__.py b/web_log/__init__.py
index e800ad8..0acc07f 100644
--- a/web_log/__init__.py
+++ b/web_log/__init__.py
@@ -1,7 +1,13 @@
 """web_log is a lightweight thin logger that logs against a logger service.
 
-Currently it logs against st-vsib:4444, but this is runtime configurable by
-setting WEBLOG_URL to whichever logger you want to use.
+Currently it logs against st-vsib:4444, but this is runtime configurable
+by setting
+
+* web_log.constants.WEBLOG_SERVER
+* web_log.constants.WEBLOG_PORT
+* web_log.constants.WEBLOG_PATH
+
+to whichever logger you want to use.
 
 Usage:
 
@@ -10,7 +16,7 @@ Usage:
 
 
 >>> from web_log import log
->>> log('My app', 'launch', user='Jenkins', extra_info='caught exception', async=True)
+>>> log('My app', 'launch', user='Jenkins', extra_info='caught exception', async_=True)
 
 Can also be used in scripts or from terminal with
 /project/res/bin/wlog MyApp MyEvent Some extra information goes here
@@ -18,9 +24,11 @@ Can also be used in scripts or from terminal with
 """
 
 from .web_log_functions import LogSync, LogAsync, log, WEBLOG_VERSION
+from .constants import *
 from .statoil_log_handler import StatoilLogHandler
+from .wlog import wlog
 
 __version__ = WEBLOG_VERSION
-__author__ = 'Software Innovation Bergen, Statoil ASA'
+__author__ = "Software Innovation, Equinor ASA"
 
-__all__ = ['LogSync', 'LogAsync', 'log', 'StatoilLogHandler']
+__all__ = ["LogSync", "LogAsync", "log", "StatoilLogHandler"]
diff --git a/web_log/constants.py b/web_log/constants.py
index 3f38b08..9e1c849 100644
--- a/web_log/constants.py
+++ b/web_log/constants.py
@@ -1,4 +1,4 @@
-_WEBLOG_SERVER = "your-logging-server.com"
-_WEBLOG_PORT = 4444
-_WEBLOG_PATH = ''
+WEBLOG_SERVER = "your-logging-server.com"
+WEBLOG_PORT = 4444
+WEBLOG_PATH = ""
 WEBLOG_TIMEOUT = 3
diff --git a/web_log/statoil_log_handler.py b/web_log/statoil_log_handler.py
index 6008f6c..ed9ed66 100644
--- a/web_log/statoil_log_handler.py
+++ b/web_log/statoil_log_handler.py
@@ -1,9 +1,13 @@
-import httplib
+try:
+    import httplib
+except ModuleNotFoundError:
+    import http.client as httplib  # TODO use requests
+
 import json
 import logging
 
-from constants import _WEBLOG_SERVER, _WEBLOG_PORT, _WEBLOG_PATH, WEBLOG_TIMEOUT
 from .web_log_functions import fill_standard_event
+import web_log
 
 
 class StatoilLogHandler(logging.Handler):
@@ -19,10 +23,12 @@ class StatoilLogHandler(logging.Handler):
         self.user = user
         self.host = host
         if host is None:
-            self.host = _WEBLOG_SERVER + ":" + str(_WEBLOG_PORT)
+            self.host = "{}:{}".format(
+                web_log.constants.WEBLOG_SERVER, web_log.constants.WEBLOG_PORT
+            )
         self.path = path
         if self.path is None:
-            self.path = _WEBLOG_PATH
+            self.path = web_log.constants.WEBLOG_PATH
 
     def emit(self, record):
         """
@@ -39,14 +45,19 @@ class StatoilLogHandler(logging.Handler):
             }
             log_dict.update(
                 fill_standard_event(
-                    self.application, record.getMessage(), extra_info=extra_from_record(record), user=self.user))
+                    self.application,
+                    record.getMessage(),
+                    extra_info=extra_from_record(record),
+                    user=self.user,
+                )
+            )
             self.postLog(log_dict)
         except Exception:
             self.handleError(record)
 
     def postLog(self, log_dict):
         host = self.host
-        h = httplib.HTTPConnection(host, timeout=WEBLOG_TIMEOUT)
+        h = httplib.HTTPConnection(host, timeout=web_log.constants.WEBLOG_TIMEOUT)
         url = self.path
         data = json.dumps(log_dict)
         h.putrequest("POST", url)
@@ -59,7 +70,7 @@ class StatoilLogHandler(logging.Handler):
         h.putheader("Content-type", "application/json")
         h.putheader("Content-length", str(len(data)))
         h.endheaders()
-        h.send(data.encode('utf-8'))
+        h.send(data.encode("utf-8"))
         h.getresponse()  # can't do anything with the result
 
 
@@ -68,28 +79,28 @@ class StatoilLogHandler(logging.Handler):
 # Used by the function extra_from_record, these are keywords which it will ignore when extracting the dictionary
 # with extra information.
 BUILTIN_ATTRS = {
-    'args',
-    'asctime',
-    'created',
-    'exc_info',
-    'exc_text',
-    'filename',
-    'funcName',
-    'levelname',
-    'levelno',
-    'lineno',
-    'module',
-    'msecs',
-    'message',
-    'msg',
-    'name',
-    'pathname',
-    'process',
-    'processName',
-    'relativeCreated',
-    'stack_info',
-    'thread',
-    'threadName',
+    "args",
+    "asctime",
+    "created",
+    "exc_info",
+    "exc_text",
+    "filename",
+    "funcName",
+    "levelname",
+    "levelno",
+    "lineno",
+    "module",
+    "msecs",
+    "message",
+    "msg",
+    "name",
+    "pathname",
+    "process",
+    "processName",
+    "relativeCreated",
+    "stack_info",
+    "thread",
+    "threadName",
 }
 
 
@@ -98,4 +109,8 @@ def extra_from_record(record):
     The `extra` keyword argument is used to populate the `__dict__` of
     the `LogRecord`.
     """
-    return {attr_name: record.__dict__[attr_name] for attr_name in record.__dict__ if attr_name not in BUILTIN_ATTRS}
+    return {
+        attr_name: record.__dict__[attr_name]
+        for attr_name in record.__dict__
+        if attr_name not in BUILTIN_ATTRS
+    }
diff --git a/web_log/web_log_functions.py b/web_log/web_log_functions.py
index ff46fb1..a3076b7 100644
--- a/web_log/web_log_functions.py
+++ b/web_log/web_log_functions.py
@@ -1,13 +1,17 @@
-import thread
+try:
+    import thread
+except ModuleNotFoundError:
+    import _thread as thread  # TODO use threading
+
 from datetime import datetime as dt
 
 import getpass
 import json
 import requests
-from constants import _WEBLOG_PATH, _WEBLOG_PORT, _WEBLOG_SERVER, WEBLOG_TIMEOUT
+import web_log
+
 
-WEBLOG_URL = "http://%s:%s%s" % (_WEBLOG_SERVER, _WEBLOG_PORT, _WEBLOG_PATH)
-WEBLOG_VERSION = '0.3.1'
+WEBLOG_VERSION = "1.1.0"
 
 
 def LogSync(application, event, user=None, extra_info=None, url=None):
@@ -25,7 +29,11 @@ def LogSync(application, event, user=None, extra_info=None, url=None):
 
     """
     if url is None:
-        url = WEBLOG_URL
+        url = "http://%s:%s%s" % (
+            web_log.constants.WEBLOG_SERVER,
+            web_log.constants.WEBLOG_PORT,
+            web_log.constants.WEBLOG_PATH,
+        )
     log_dict = fill_standard_event(application, event, extra_info, user)
 
     headers = {"content-type": "application/json"}
@@ -35,15 +43,24 @@ def LogSync(application, event, user=None, extra_info=None, url=None):
     # requests from the same executable, we just look another way.
     try:
         from requests.packages.urllib3.exceptions import InsecureRequestWarning
+
         requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
     except ImportError:
         pass
 
     try:
         data = json.dumps(log_dict)
-        response = requests.post(url, data=data, headers=headers, verify=False, timeout=WEBLOG_TIMEOUT, proxies=proxies)
+        response = requests.post(
+            url,
+            data=data,
+            headers=headers,
+            verify=False,
+            timeout=web_log.constants.WEBLOG_TIMEOUT,
+            proxies=proxies,
+        )
         return True
-    except:
+    except Exception as err:
+        print(err)
         return False
 
 
@@ -55,9 +72,9 @@ def fill_standard_event(application, event, extra_info=None, user=None):
     if user is None:
         user = getpass.getuser()
     extra_dict = {}
-    extra = ''
+    extra = ""
     if extra_info:
-        if isinstance(extra_info, basestring):
+        if isinstance(extra_info, str):
             extra = extra_info
         elif isinstance(extra_info, dict):
             extra_dict = extra_info
@@ -68,11 +85,11 @@ def fill_standard_event(application, event, extra_info=None, user=None):
         "event": event,
         "user": user,
         "node_timestamp": dt.utcnow().isoformat(),
-        "logger": "%s %s" % (__file__, WEBLOG_VERSION)
+        "logger": "%s %s" % (__file__, WEBLOG_VERSION),
     }
     log_dict.update(extra_dict)
     if extra:
-        log_dict['extra_info'] = extra
+        log_dict["extra_info"] = extra
     return log_dict
 
 
@@ -81,13 +98,15 @@ def LogAsync(application, event, user=None, extra_info=None, url=None):
     thread.start_new_thread(LogSync, (application, event, user, extra_info, url))
 
 
-def log(application, event, user=None, extra_info=None, async=False, url=None):
+def log(
+    application, event, user=None, extra_info=None, url=None, async_=False, **kwargs
+):
     """Shorthand for either LogSync or LogAsync.  Will log asynchronously if
     async=True, that is, it will start a new thread for performing requests.
     Otherwise it blocks until the logging request is performed.
     """
 
-    if async:
+    if async_ or "async" in kwargs and kwargs["async"]:
         LogAsync(application, event, user=user, extra_info=extra_info, url=url)
     else:
         LogSync(application, event, user=user, extra_info=extra_info, url=url)
diff --git a/web_log/wlog.py b/web_log/wlog.py
new file mode 100644
index 0000000..983a891
--- /dev/null
+++ b/web_log/wlog.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+"""wlog is a lightweight thin logger that logs against a logger service.
+
+"""
+
+import web_log
+
+_USAGE = """weblog version {}
+
+Usage: wlog application event [extra]\n
+       wlog -v (--version)
+       wlog -h (--help)
+""".format(
+    web_log.WEBLOG_VERSION
+)
+
+
+def _exit_with_msg(msg, exitcode=0):
+    print(msg)
+    exit(exitcode)
+
+
+def wlog():
+    import sys
+
+    args = sys.argv
+    if len(args) == 2 and args[1] in ("-v", "--version"):
+        _exit_with_msg("wlog {}".format(web_log.WEBLOG_VERSION))
+    if len(args) == 2 and args[1] in ("-h", "--help"):
+        _exit_with_msg(_USAGE)
+    if len(args) < 3:
+        _exit_with_msg(_USAGE, exitcode=1)
+
+    extra = ""
+    application = args[1]
+    event = args[2]
+    extra = " ".join(args[3:])
+    web_log.log(application, event, extra_info=extra)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/tests/mock.py b/tests/mock.py
new file mode 100644
index 0000000..c2b9e88
--- /dev/null
+++ b/tests/mock.py
@@ -0,0 +1,61 @@
+import time
+import contextlib
+import web_log
+
+try:
+    from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
+except ModuleNotFoundError:
+    from http.server import BaseHTTPRequestHandler, HTTPServer
+
+
+try:
+    import SocketServer as socketserver
+except ModuleNotFoundError:
+    import socketserver
+
+
+web_log.constants.WEBLOG_SERVER = "localhost"
+web_log.constants.WEBLOG_PORT = 5254
+web_log.constants.WEBLOG_PATH = ""
+web_log.constants.WEBLOG_TIMEOUT = 3
+
+OUTPUT = []
+
+
+class _MockHandler(BaseHTTPRequestHandler):
+    def do_POST(self):
+        content_length = int(self.headers["Content-Length"])
+        post_data = self.rfile.read(content_length)
+        self.send_response(200)
+        self.send_header("Content-type", "text/html")
+        self.end_headers()
+        OUTPUT.append(post_data)
+
+
+def _init_server():
+    try:
+        OUTPUT.clear()
+    except AttributeError:
+        del OUTPUT[:]  # Python <3.3
+
+    server = web_log.constants.WEBLOG_SERVER
+    port = web_log.constants.WEBLOG_PORT
+    return HTTPServer((server, port), _MockHandler)
+
+
+@contextlib.contextmanager
+def serve():
+    import threading
+
+    mock = _init_server()
+    t = threading.Thread(target=mock.serve_forever)
+    t.start()
+
+    try:
+        time.sleep(0.5)
+        yield
+        time.sleep(0.5)
+    finally:
+        mock.shutdown()
+
+    t.join()
diff --git a/tests/test_log_handler.py b/tests/test_log_handler.py
new file mode 100644
index 0000000..93edeab
--- /dev/null
+++ b/tests/test_log_handler.py
@@ -0,0 +1,49 @@
+import json
+import logging
+import mock
+
+from web_log import StatoilLogHandler
+
+
+def test_log_handler():
+    with mock.serve():
+        logger = logging.getLogger("test")
+        logger.addHandler(StatoilLogHandler("TestHandler"))
+        logger.info("Is ignored")
+        logger.warning("A warning")
+
+    print(mock.OUTPUT)
+    assert len(mock.OUTPUT) >= 1  # might be other warnings
+    found_warning = False  # find "A warning"
+    for log in mock.OUTPUT:
+        msg = log.decode("UTF-8")
+        data = json.loads(msg)
+        if data.get("event") == "A warning":
+            assert data.get("application") == "TestHandler"
+            assert "extra_info" not in data
+            assert data["loglevel"] == "WARNING"
+            found_warning = True
+    assert found_warning
+
+
+def test_log_handler_info():
+    with mock.serve():
+        logger = logging.getLogger("test")
+        logger.addHandler(StatoilLogHandler("TestHandler2"))
+        logger.setLevel(logging.INFO)
+        logger.info("Is not ignored")
+        logger.warning("A warning")
+
+    loglevels = {"Is not ignored": "INFO", "A warning": "WARNING"}
+
+    assert len(mock.OUTPUT) >= 2  # might be more logging
+    for log in mock.OUTPUT:
+        msg = log.decode("UTF-8")
+        data = json.loads(msg)
+        if data.get("application", "") == "TestHandler2":
+            assert "event" in data
+            assert data["event"] in loglevels
+            key = data["event"]
+            assert data["loglevel"] == loglevels.pop(key)
+
+    assert len(loglevels) == 0
diff --git a/tests/test_script.py b/tests/test_script.py
new file mode 100644
index 0000000..9c6ffa4
--- /dev/null
+++ b/tests/test_script.py
@@ -0,0 +1,7 @@
+import os
+
+
+def test_script():
+    assert os.system("which wlog") == 0
+    assert os.system("wlog -h") == 0
+    assert os.system("wlog x") >> 8 == 1  # 16 bit error code
diff --git a/tests/test_wlog.py b/tests/test_wlog.py
new file mode 100644
index 0000000..87ffcd3
--- /dev/null
+++ b/tests/test_wlog.py
@@ -0,0 +1,27 @@
+import json
+import web_log
+import mock
+
+
+def test_server():
+    with mock.serve():
+        web_log.log("TestApp", "TestEvent", async_=False)
+        web_log.log("TestApp2", "TestEvent2", extra_info="a b c", async_=True)
+
+    assert len(mock.OUTPUT) == 2
+    msg0 = mock.OUTPUT[0].decode("UTF-8")
+    data0 = json.loads(msg0)
+    assert "application" in data0
+    assert data0["application"] == "TestApp"
+    assert "event" in data0
+    assert data0["event"] == "TestEvent"
+    assert "extra_info" not in data0
+
+    msg1 = mock.OUTPUT[1].decode("UTF-8")
+    data1 = json.loads(msg1)
+    assert "application" in data1
+    assert data1["application"] == "TestApp2"
+    assert "event" in data1
+    assert data1["event"] == "TestEvent2"
+    assert "extra_info" in data1
+    assert data1["extra_info"] == "a b c"