diff --git a/README.md b/README.md
index bcef271..9d717a2 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,24 @@ def handler(payload, root):
gcn.listen(handler=handler)
```
-## Filtering
+## Server-Side Filtering
+
+VOEvent brokers that are powered by [Comet](https://comet.transientskp.org/) support [server-side filtering of alerts](https://comet.transientskp.org/en/stable/filtering.html). You configure the server-side filtering when you connect by supplying an [XPath expression](https://www.w3schools.com/xml/xpath_syntax.asp) in the optional `filter` argument for `gcn.listen`:
+
+```python
+gcn.listen(handler=handler, filter='insert-filter-here')
+```
+
+Here is a cheat sheet for some common filter expressions.
+
+| Filter expression | What it does |
+| - | - |
+| `//Param[@name="Packet_Type" and @value="115"]` | Pass only alerts of notice type 115 (`FERMI_GBM_FIN_POS`) |
+| `//Param[@name="Packet_Type" and @value="115"] and //Error2Radius<=6` | Pass only alerts of notice type 115 (`FERMI_GBM_FIN_POS`) with error radius less than or equal to 6° |
+| `//Param[@name="Packet_Type" and (@value="112" or @value="115")]` | Pass only alerts of notice type 112 (`FERMI_GBM_GND_POS`) or 115 (`FERMI_GBM_FIN_POS`) |
+| `starts-with(@ivorn, "ivo://gwnet/") and @role!="test"` | Pass only LIGO-Virgo-KAGRA gravitational-wave alerts that are not test alerts |
+
+## Client-Side Filtering
You can also filter events by notice type using
`gcn.include_notice_types` or `gcn.exclude_notice_types`.
diff --git a/gcn/cmdline.py b/gcn/cmdline.py
index cad594b..aecc7db 100644
--- a/gcn/cmdline.py
+++ b/gcn/cmdline.py
@@ -76,6 +76,11 @@ def listen_main(args=None):
'provided, then loop over hosts until a connection '
'with one of them is established. '
'(default: %(default)s)')
+ parser.add_argument(
+ '--filter', help='Optional XPath expression for server-side event '
+ 'filtering. Only supported by Comet brokers. See '
+ 'https://comet.transientskp.org/en/stable/filtering.html for '
+ 'supported syntax')
parser.add_argument('--version', action='version',
version='pygcn ' + __version__)
args = parser.parse_args(args)
@@ -85,7 +90,7 @@ def listen_main(args=None):
# Listen for GCN notices (until interrupted or killed)
host, port = [list(_) for _ in zip(*args.addr)]
- listen(host=host, port=port, handler=handlers.archive)
+ listen(host=host, port=port, handler=handlers.archive, filter=args.filter)
def serve_main(args=None):
diff --git a/gcn/voeventclient.py b/gcn/voeventclient.py
index 33d2753..dc6c335 100644
--- a/gcn/voeventclient.py
+++ b/gcn/voeventclient.py
@@ -25,6 +25,7 @@
import struct
import time
import itertools
+from xml.sax.saxutils import quoteattr
from lxml.etree import fromstring, XMLSyntaxError
@@ -42,7 +43,7 @@
def _get_now_iso8601():
"""Get current date-time in ISO 8601 format."""
- return datetime.datetime.now().isoformat()
+ return datetime.datetime.now().isoformat() + "Z"
def _open_socket(hosts_ports, iamalive_timeout, max_reconnect_timeout, log):
@@ -121,7 +122,7 @@ def _send_packet(sock, payload):
sock.sendall(_size_struct.pack(len(payload)) + payload)
-def _form_response(role, origin, response, timestamp):
+def _form_response(role, origin, response, timestamp, meta=''):
"""Form a VOEvent Transport Protocol packet suitable for sending an `ack`
or `iamalive` response."""
return (
@@ -133,11 +134,12 @@ def _form_response(role, origin, response, timestamp):
'Transport/v1.1 '
'http://telescope-networks.org/schema/Transport-v1.1.xsd">' +
origin + '' + response +
- '' + timestamp +
- '').encode('UTF-8')
+ '' + '' + meta + ''
+ + '' + timestamp + ''
+ ).encode('UTF-8')
-def _ingest_packet(sock, ivorn, handler, log):
+def _ingest_packet(sock, ivorn, handler, log, filter):
"""Ingest one VOEvent Transport Protocol packet and act on it, first
sending the appropriate response and then calling the handler if the
payload is a VOEvent."""
@@ -163,6 +165,13 @@ def _ingest_packet(sock, ivorn, handler, log):
root.find("Origin").text, ivorn,
_get_now_iso8601()))
log.debug("sent iamalive response")
+ elif root.attrib["role"] == "authenticate" and filter is not None:
+ log.debug("received authenticate message")
+ _send_packet(sock, _form_response("authenticate",
+ root.find("Origin").text, ivorn,
+ _get_now_iso8601(),
+ f''))
+ log.debug("sent authenticate response")
else:
log.error(
'received transport message with unrecognized role: %s',
@@ -212,7 +221,7 @@ def _validate_host_port(host, port):
def listen(host=("45.58.43.186", "68.169.57.253"), port=8099,
ivorn="ivo://python_voeventclient/anonymous", iamalive_timeout=150,
- max_reconnect_timeout=1024, handler=None, log=None):
+ max_reconnect_timeout=1024, handler=None, log=None, filter=None):
"""Connect to a VOEvent Transport Protocol server on the given `host` and
`port`, then listen for VOEvents until interrupted (i.e., by a keyboard
interrupt, `SIGINTR`, or `SIGTERM`).
@@ -233,10 +242,17 @@ def listen(host=("45.58.43.186", "68.169.57.253"), port=8099,
used for reporting the client's status. If `log` is not provided, a default
logger will be used.
+ If `filter` is provided, then it is passed to the server as an
+ `XPath filtering expression
+ `_.
+
Note that this function does not return."""
if log is None:
log = logging.getLogger('gcn.listen')
+ if filter is not None:
+ filter = quoteattr(filter)
+
hosts_ports = itertools.cycle(zip(*_validate_host_port(host, port)))
while True:
@@ -246,7 +262,7 @@ def listen(host=("45.58.43.186", "68.169.57.253"), port=8099,
try:
while True:
- _ingest_packet(sock, ivorn, handler, log)
+ _ingest_packet(sock, ivorn, handler, log, filter)
except socket.timeout:
log.warn("timed out")
except socket.error: