diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1dbab7c..10ca5f4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,6 +33,8 @@ jobs: toxenv: py314 - python-version: "3.x" toxenv: no-flask-caching + - python-version: "3.12" + toxenv: cairo steps: - uses: actions/checkout@v6 diff --git a/docs/installation.rst b/docs/installation.rst index 59748c2..457c620 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -12,10 +12,10 @@ Installing Graphite-Render requires: * Python 3 (3.9 and above). -* Cairo. On debian/ubuntu, install the ``libcairo2`` package. - * Pip, the Python package manager. On debian/ubuntu, install ``python-pip``. +* For image rendering (PNG/SVG/PDF graphs): Cairo. On debian/ubuntu, install the ``libcairo2`` package. + Global installation ------------------- diff --git a/graphite_render/render/glyph.py b/graphite_render/render/glyph.py index aa9a6e3..ac2fa6f 100644 --- a/graphite_render/render/glyph.py +++ b/graphite_render/render/glyph.py @@ -21,11 +21,27 @@ from urllib.parse import unquote_plus from zoneinfo import ZoneInfo -import cairocffi as cairo - from .datalib import TimeSeries from ..utils import to_seconds +# Lazy import for cairocffi - only loaded when actually used in the render pipeline +cairo = None + + +def _get_cairo(): + """Lazily import cairocffi when needed for rendering.""" + global cairo + if cairo is None: + try: + import cairocffi as cairo_module + cairo = cairo_module + except ImportError as e: + raise ImportError( + "cairocffi is required for image rendering but is not installed. " + "Install it with: pip install cairocffi" + ) from e + return cairo + INFINITY = float('inf') @@ -737,7 +753,7 @@ def __init__(self, **params): self.loadTemplate(params.get('template', 'default')) opts = self.ctx.get_font_options() - opts.set_antialias(cairo.ANTIALIAS_NONE) + opts.set_antialias(self.cairo.ANTIALIAS_NONE) self.ctx.set_font_options(opts) self.foregroundColor = params.get('fgcolor', self.defaultForeground) @@ -755,22 +771,23 @@ def __init__(self, **params): def setupCairo(self, outputFormat='png'): self.outputFormat = outputFormat + self.cairo = _get_cairo() if outputFormat == 'png': - self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, - self.width, self.height) + self.surface = self.cairo.ImageSurface(self.cairo.FORMAT_ARGB32, + self.width, self.height) elif outputFormat == 'svg': self.surfaceData = BytesIO() - self.surface = cairo.SVGSurface(self.surfaceData, - self.width, self.height) + self.surface = self.cairo.SVGSurface(self.surfaceData, + self.width, self.height) elif outputFormat == 'pdf': self.surfaceData = BytesIO() - self.surface = cairo.PDFSurface(self.surfaceData, - self.width, self.height) + self.surface = self.cairo.PDFSurface(self.surfaceData, + self.width, self.height) res_x, res_y = self.surface.get_fallback_resolution() self.width = float(self.width / res_x) * 72 self.height = float(self.height / res_y) * 72 self.surface.set_size(self.width, self.height) - self.ctx = cairo.Context(self.surface) + self.ctx = self.cairo.Context(self.surface) def setColor(self, value, alpha=1.0, forceAlpha=False): if isinstance(value, tuple) and len(value) == 3: @@ -1395,14 +1412,14 @@ def drawLines(self, width=None, dash=None, linecap='butt', else: self.ctx.set_dash([], 0) self.ctx.set_line_cap({ - 'butt': cairo.LINE_CAP_BUTT, - 'round': cairo.LINE_CAP_ROUND, - 'square': cairo.LINE_CAP_SQUARE, + 'butt': self.cairo.LINE_CAP_BUTT, + 'round': self.cairo.LINE_CAP_ROUND, + 'square': self.cairo.LINE_CAP_SQUARE, }[linecap]) self.ctx.set_line_join({ - 'miter': cairo.LINE_JOIN_MITER, - 'round': cairo.LINE_JOIN_ROUND, - 'bevel': cairo.LINE_JOIN_BEVEL, + 'miter': self.cairo.LINE_JOIN_MITER, + 'round': self.cairo.LINE_JOIN_ROUND, + 'bevel': self.cairo.LINE_JOIN_BEVEL, }[linejoin]) # check whether there is an stacked metric diff --git a/tests/test_http.py b/tests/test_http.py index 3754a8b..612950e 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -26,8 +26,8 @@ def test_cors(self): 'Access-Control-Allow-Origin' in response.headers) def test_trailing_slash(self): - response = self.app.get('/render?target=foo') + response = self.app.get('/render?target=foo&format=json') self.assertEqual(response.status_code, 200) - response = self.app.get('/render/?target=foo') + response = self.app.get('/render/?target=foo&format=json') self.assertEqual(response.status_code, 200) diff --git a/tests/test_render.py b/tests/test_render.py index dd8ed27..1efbe4d 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,4 +1,5 @@ # coding: utf-8 +"""Tests that don't require Cairo (JSON/raw/csv/dygraph/rickshaw formats).""" import json import os import time @@ -7,13 +8,9 @@ from . import TestCase, WHISPER_DIR -try: - from flask_caching import Cache -except ImportError: - Cache = None - class RenderTest(TestCase): + """Tests that don't require Cairo - use JSON/raw/csv/dygraph/rickshaw formats.""" db = os.path.join(WHISPER_DIR, 'test.wsp') url = '/render' @@ -25,35 +22,32 @@ def create_db(self): whisper.update(self.db, 0.5, self.ts - 1) whisper.update(self.db, 1.5, self.ts) - def test_render_view(self): + def test_render_view_json(self): response = self.app.get(self.url, query_string={'target': 'test', 'format': 'json', 'noCache': 'true'}) self.assertEqual(json.loads(response.data.decode('utf-8')), []) + def test_render_view_raw(self): response = self.app.get(self.url, query_string={'target': 'test', 'format': 'raw', 'noCache': 'true'}) self.assertEqual(response.data.decode('utf-8'), "") self.assertEqual(response.headers['Content-Type'], 'text/plain') - response = self.app.get(self.url, query_string={'target': 'test', - 'format': 'pdf'}) - self.assertEqual(response.headers['Content-Type'], 'application/x-pdf') - - response = self.app.get(self.url, query_string={'target': 'test'}) - self.assertEqual(response.headers['Content-Type'], 'image/png') - + def test_render_view_dygraph(self): response = self.app.get(self.url, query_string={'target': 'test', 'format': 'dygraph', 'noCache': 'true'}) self.assertEqual(json.loads(response.data.decode('utf-8')), {}) + def test_render_view_rickshaw(self): response = self.app.get(self.url, query_string={'target': 'test', 'format': 'rickshaw', 'noCache': 'true'}) self.assertEqual(json.loads(response.data.decode('utf-8')), []) + def test_render_view_with_data(self): self.create_db() response = self.app.get(self.url, query_string={'target': 'test', 'format': 'json'}) @@ -138,11 +132,7 @@ def test_render_view(self): {'x': self.ts, 'y': 1.5}, {'x': self.ts + 1, 'y': None}]) - def test_render_constant_line(self): - response = self.app.get(self.url, query_string={ - 'target': 'constantLine(12)'}) - self.assertEqual(response.headers['Content-Type'], 'image/png') - + def test_render_constant_line_json(self): response = self.app.get(self.url, query_string={ 'target': 'constantLine(12)', 'format': 'json'}) data = json.loads(response.data.decode('utf-8'))[0]['datapoints'] @@ -232,127 +222,6 @@ def test_correct_timezone(self): data = json.loads(response.data.decode('utf-8'))[0]['datapoints'] self.assertEqual(data, expected) - def test_render_options(self): - self.create_db() - db2 = os.path.join(WHISPER_DIR, 'foo.wsp') - whisper.create(db2, [(1, 60)]) - ts = int(time.time()) - whisper.update(db2, 0.5, ts - 2) - - for qs in [ - {'logBase': 'e'}, - {'logBase': 1}, - {'logBase': 0.5}, - {'logBase': 10}, - {'margin': -1}, - {'colorList': 'orange,green,blue,#0f00f0'}, - {'bgcolor': 'orange'}, - {'bgcolor': '000000'}, - {'bgcolor': '#000000'}, - {'bgcolor': '123456'}, - {'bgcolor': '#123456'}, - {'bgcolor': '#12345678'}, - {'bgcolor': 'aaabbb'}, - {'bgcolor': '#aaabbb'}, - {'bgcolor': '#aaabbbff'}, - {'fontBold': 'true'}, - {'title': 'Hellò'}, - {'title': 'true'}, - {'vtitle': 'Hellò'}, - {'title': 'Hellò', 'yAxisSide': 'right'}, - {'uniqueLegend': 'true', '_expr': 'secondYAxis({0})'}, - {'uniqueLegend': 'true', 'vtitleRight': 'foo', - '_expr': 'secondYAxis({0})'}, - {'rightWidth': '1', '_expr': 'secondYAxis({0})'}, - {'rightDashed': '1', '_expr': 'secondYAxis({0})'}, - {'rightColor': 'black', '_expr': 'secondYAxis({0})'}, - {'leftWidth': '1', 'target': ['secondYAxis(foo)', 'test']}, - {'leftDashed': '1', 'target': ['secondYAxis(foo)', 'test']}, - {'leftColor': 'black', 'target': ['secondYAxis(foo)', 'test']}, - {'width': '10', '_expr': 'secondYAxis({0})'}, - {'logBase': 'e', 'target': ['secondYAxis(foo)', 'test']}, - {'graphOnly': 'true', 'yUnitSystem': 'si'}, - {'graphOnly': 'true', 'yUnitSystem': 'wat'}, - {'lineMode': 'staircase'}, - {'lineMode': 'slope'}, - {'lineMode': 'slope', 'from': '-1s'}, - {'lineMode': 'connected'}, - {'min': 1, 'max': 2, 'thickness': 2, 'yUnitSystem': 'none'}, - {'yMax': 5, 'yLimit': 0.5, 'yStep': 0.1}, - {'yMax': 'max', 'yUnitSystem': 'binary'}, - {'yMaxLeft': 5, 'yLimitLeft': 0.5, 'yStepLeft': 0.1, - '_expr': 'secondYAxis({0})'}, - {'yMaxRight': 5, 'yLimitRight': 0.5, 'yStepRight': 0.1, - '_expr': 'secondYAxis({0})'}, - {'yMin': 0, 'yLimit': 0.5, 'yStep': 0.1}, - {'yMinLeft': 0, 'yLimitLeft': 0.5, 'yStepLeft': 0.1, - '_expr': 'secondYAxis({0})'}, - {'yMinRight': 0, 'yLimitRight': 0.5, 'yStepRight': 0.1, - '_expr': 'secondYAxis({0})'}, - {'areaMode': 'stacked', '_expr': 'stacked({0})'}, - {'lineMode': 'staircase', '_expr': 'stacked({0})'}, - {'areaMode': 'first', '_expr': 'stacked({0})'}, - {'areaMode': 'all', '_expr': 'stacked({0})'}, - {'areaMode': 'all', 'areaAlpha': 0.5, '_expr': 'secondYAxis({0})'}, - {'areaMode': 'all', 'areaAlpha': 0.5, - 'target': ['secondYAxis(foo)', 'test']}, - {'areaMode': 'stacked', 'areaAlpha': 0.5, '_expr': 'stacked({0})'}, - {'areaMode': 'stacked', 'areaAlpha': 'a', '_expr': 'stacked({0})'}, - {'areaMode': 'stacked', '_expr': 'drawAsInfinite({0})'}, - {'_expr': 'dashed(lineWidth({0}, 5))'}, - {'target': 'areaBetween(*)'}, - {'drawNullAsZero': 'true'}, - {'_expr': 'drawAsInfinite({0})'}, - {'graphType': 'pie', 'pieMode': 'average', 'title': 'Pie'}, - {'graphType': 'pie', 'pieMode': 'maximum', 'title': 'Pie'}, - {'graphType': 'pie', 'pieMode': 'minimum', 'title': 'Pie'}, - {'graphType': 'pie', 'pieMode': 'average', 'hideLegend': 'true'}, - {'graphType': 'pie', 'pieMode': 'average', 'valueLabels': 'none'}, - {'graphType': 'pie', 'pieMode': 'average', - 'valueLabels': 'number'}, - {'graphType': 'pie', 'pieMode': 'average', 'pieLabels': 'rotated'}, - {'graphType': 'pie', 'pieMode': 'average', 'areaAlpha': '0.1'}, - {'graphType': 'pie', 'pieMode': 'average', 'areaAlpha': 'none'}, - {'graphType': 'pie', 'pieMode': 'average', - 'valueLabelsColor': 'white'}, - {'noCache': 'true'}, - {'cacheTimeout': 5}, - {'cacheTimeout': 5}, # cache hit - {'tz': 'Europe/Berlin'}, - ]: - if qs.setdefault('target', ['foo', 'test']) == ['foo', 'test']: - if '_expr' in qs: - expr = qs.pop('_expr') - qs['target'] = [expr.format(t) for t in qs['target']] - response = self.app.get(self.url, query_string=qs) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.headers['Content-Type'], 'image/png') - if Cache is None or qs.get('noCache'): - self.assertEqual(response.headers['Pragma'], 'no-cache') - self.assertEqual(response.headers['Cache-Control'], 'no-cache') - self.assertFalse('Expires' in response.headers) - else: - self.assertEqual(response.headers['Cache-Control'], - 'max-age={0}'.format( - qs.get('cacheTimeout', 60))) - self.assertNotEqual(response.headers['Cache-Control'], - 'no-cache') - self.assertFalse('Pragma' in response.headers) - - for qs in [ - {'bgcolor': 'foo'}, - ]: - qs['target'] = 'test' - with self.assertRaises(ValueError): - response = self.app.get(self.url, query_string=qs) - - for qs in [ - {'lineMode': 'stacked'}, - ]: - qs['target'] = 'test' - with self.assertRaises(AssertionError): - response = self.app.get(self.url, query_string=qs) - def test_render_validation(self): whisper.create(self.db, [(1, 60)]) @@ -380,15 +249,6 @@ def test_render_validation(self): 'until': 'Invalid empty time range', }}, status_code=400) - response = self.app.get(self.url, query_string={ - 'target': 'foo', - 'width': 100, - 'thickness': '1.5', - 'fontBold': 'true', - 'fontItalic': 'default', - }) - self.assertEqual(response.status_code, 200) - response = self.app.get(self.url, query_string={ 'target': 'foo', 'tz': 'Europe/Lausanne'}) self.assertJSON(response, {'errors': { @@ -401,13 +261,8 @@ def test_render_validation(self): 'target': "Invalid target: 'test:aa'.", }}, status_code=400) - response = self.app.get(self.url, query_string={ - 'target': ['test', 'foo:1.2'], 'graphType': 'pie'}) - self.assertEqual(response.status_code, 200) - - response = self.app.get(self.url, query_string={'target': ['test', - '']}) - self.assertEqual(response.status_code, 200) + def test_render_validation_csv(self): + whisper.create(self.db, [(1, 60)]) response = self.app.get(self.url, query_string={'target': 'test', 'format': 'csv'}) @@ -416,41 +271,6 @@ def test_render_validation(self): self.assertTrue(len(lines) in [59, 60]) self.assertFalse(any([l.strip().split(',')[2] for l in lines])) - response = self.app.get(self.url, query_string={'target': 'test', - 'format': 'svg', - 'jsonp': 'foo'}) - jsonpsvg = response.data.decode('utf-8') - self.assertTrue(jsonpsvg.startswith('foo("\\n")')) - - response = self.app.get(self.url, query_string={'target': 'test', - 'format': 'svg'}) - svg = response.data.decode('utf-8') - self.assertTrue(svg.startswith('\\n")')) + + response = self.app.get(self.url, query_string={'target': 'test', + 'format': 'svg'}) + svg = response.data.decode('utf-8') + self.assertTrue(svg.startswith('