From 0c6cc56acdc5e61ac6d5067de5d11df2a90d4d16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:52:15 +0000 Subject: [PATCH 01/10] Initial plan From 81f65841b9f31a71f98fec357299a49846c17607 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:58:36 +0000 Subject: [PATCH 02/10] Make cairocffi a lazy import and optional dependency Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- docs/installation.rst | 15 +++++++++--- graphite_render/render/glyph.py | 43 +++++++++++++++++++++------------ pyproject.toml | 2 +- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 59748c2..5bbf752 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 ------------------- @@ -23,6 +23,10 @@ To install Graphite-Render globally on your system, run as root:: $ pip install graphite-render +For image rendering support (PNG/SVG/PDF graphs), install with the render extra:: + + $ pip install graphite-render[render] + Isolated installation (virtualenv) ---------------------------------- @@ -32,7 +36,7 @@ you can install it in a virtualenv. :: $ virtualenv /usr/share/python/graphite - $ /usr/share/python/graphite/bin/pip install graphite-render + $ /usr/share/python/graphite/bin/pip install graphite-render[render] .. _extras: @@ -43,6 +47,9 @@ When you install ``graphite-render``, all the dependencies for running a Graphit server that uses Whisper as a storage backend are installed. You can specify extra dependencies: +* For image rendering (PNG/SVG/PDF): ``pip install graphite-render[render]``. + You'll also need Cairo installed on your system (``libcairo2`` on debian/ubuntu). + * For `Sentry`_ integration: ``pip install graphite-render[sentry]``. * For `Cyanite`_ integration: ``pip install graphite-render[cyanite]``. @@ -58,4 +65,4 @@ extra dependencies: You can also combine several extra dependencies:: - $ pip install graphite-render[sentry,cyanite] + $ pip install graphite-render[render,sentry,cyanite] diff --git a/graphite_render/render/glyph.py b/graphite_render/render/glyph.py index aa9a6e3..f859a80 100644 --- a/graphite_render/render/glyph.py +++ b/graphite_render/render/glyph.py @@ -21,11 +21,21 @@ 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: + import cairocffi as cairo_module + cairo = cairo_module + return cairo + INFINITY = float('inf') @@ -737,7 +747,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 +765,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 +1406,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/pyproject.toml b/pyproject.toml index ffe3f2b..5f991e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ classifiers = [ dependencies = [ "Flask", "PyYAML", - "cairocffi", "pyparsing>=3.0.0", "structlog", "tzlocal", @@ -48,6 +47,7 @@ cyanite = ["cyanite"] cache = ["flask-caching"] statsd = ["statsd"] docs = ["sphinx"] +render = ["cairocffi"] [project.urls] Homepage = "https://github.com/csett86/graphite-render" From 8aa4827ab71729de99bc75c610df37a4d2666317 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:00:30 +0000 Subject: [PATCH 03/10] Add helpful error message when cairocffi is not installed Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- graphite_render/render/glyph.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/graphite_render/render/glyph.py b/graphite_render/render/glyph.py index f859a80..53d2cc6 100644 --- a/graphite_render/render/glyph.py +++ b/graphite_render/render/glyph.py @@ -32,8 +32,14 @@ def _get_cairo(): """Lazily import cairocffi when needed for rendering.""" global cairo if cairo is None: - import cairocffi as cairo_module - cairo = cairo_module + 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 graphite-render[render]" + ) from e return cairo From cf391a134abc39042d1ea8ca70c70d25370f3117 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:12:29 +0000 Subject: [PATCH 04/10] Add separate tox test environment for Cairo-dependent tests Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- tox.ini | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index dbe06f6..c22f176 100644 --- a/tox.ini +++ b/tox.ini @@ -6,14 +6,26 @@ envlist = py312, py313, py314, - no-flask-caching + no-flask-caching, + render [testenv] setenv = PYTHONPATH={toxinidir} GRAPHITE_API_CONFIG={toxinidir}/tests/conf.yaml commands = - python -Wall -m unittest discover + python -Wall -m unittest \ + tests.test_attime \ + tests.test_carbonlink \ + tests.test_config \ + tests.test_encoders \ + tests.test_finders \ + tests.test_functions \ + tests.test_intervals \ + tests.test_metrics \ + tests.test_paths \ + tests.test_render_datalib \ + tests.test_storage deps = .[sentry,cache] @@ -39,3 +51,10 @@ basepython = python3.14 basepython = python3.12 deps = .[sentry] + +[testenv:render] +basepython = python3.12 +deps = + .[sentry,cache,render] +commands = + python -Wall -m unittest tests.test_render tests.test_render_glyph tests.test_http From 4152d2c5eaebd8d5563403fd26ef7bdf6d7e12b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:16:34 +0000 Subject: [PATCH 05/10] Rename extra feature and test environment from 'render' to 'cairo' Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- docs/installation.rst | 10 +++++----- graphite_render/render/glyph.py | 2 +- pyproject.toml | 2 +- tox.ini | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 5bbf752..543c4c3 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -23,9 +23,9 @@ To install Graphite-Render globally on your system, run as root:: $ pip install graphite-render -For image rendering support (PNG/SVG/PDF graphs), install with the render extra:: +For image rendering support (PNG/SVG/PDF graphs), install with the cairo extra:: - $ pip install graphite-render[render] + $ pip install graphite-render[cairo] Isolated installation (virtualenv) ---------------------------------- @@ -36,7 +36,7 @@ you can install it in a virtualenv. :: $ virtualenv /usr/share/python/graphite - $ /usr/share/python/graphite/bin/pip install graphite-render[render] + $ /usr/share/python/graphite/bin/pip install graphite-render[cairo] .. _extras: @@ -47,7 +47,7 @@ When you install ``graphite-render``, all the dependencies for running a Graphit server that uses Whisper as a storage backend are installed. You can specify extra dependencies: -* For image rendering (PNG/SVG/PDF): ``pip install graphite-render[render]``. +* For image rendering (PNG/SVG/PDF): ``pip install graphite-render[cairo]``. You'll also need Cairo installed on your system (``libcairo2`` on debian/ubuntu). * For `Sentry`_ integration: ``pip install graphite-render[sentry]``. @@ -65,4 +65,4 @@ extra dependencies: You can also combine several extra dependencies:: - $ pip install graphite-render[render,sentry,cyanite] + $ pip install graphite-render[cairo,sentry,cyanite] diff --git a/graphite_render/render/glyph.py b/graphite_render/render/glyph.py index 53d2cc6..952f9bd 100644 --- a/graphite_render/render/glyph.py +++ b/graphite_render/render/glyph.py @@ -38,7 +38,7 @@ def _get_cairo(): except ImportError as e: raise ImportError( "cairocffi is required for image rendering but is not installed. " - "Install it with: pip install graphite-render[render]" + "Install it with: pip install graphite-render[cairo]" ) from e return cairo diff --git a/pyproject.toml b/pyproject.toml index 5f991e0..48250a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ cyanite = ["cyanite"] cache = ["flask-caching"] statsd = ["statsd"] docs = ["sphinx"] -render = ["cairocffi"] +cairo = ["cairocffi"] [project.urls] Homepage = "https://github.com/csett86/graphite-render" diff --git a/tox.ini b/tox.ini index c22f176..8f98489 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ envlist = py313, py314, no-flask-caching, - render + cairo [testenv] setenv = @@ -52,9 +52,9 @@ basepython = python3.12 deps = .[sentry] -[testenv:render] +[testenv:cairo] basepython = python3.12 deps = - .[sentry,cache,render] + .[sentry,cache,cairo] commands = python -Wall -m unittest tests.test_render tests.test_render_glyph tests.test_http From 7a90276617fd044d717705cd964856d7b613c5be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:25:15 +0000 Subject: [PATCH 06/10] Split test_render tests between Cairo-dependent and non-Cairo tests Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- tests/test_render.py | 209 ++------------------------------- tests/test_render_cairo.py | 230 +++++++++++++++++++++++++++++++++++++ tox.ini | 3 +- 3 files changed, 242 insertions(+), 200 deletions(-) create mode 100644 tests/test_render_cairo.py 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(' Date: Sun, 18 Jan 2026 16:30:57 +0000 Subject: [PATCH 07/10] Make test_http work without Cairo by using JSON format Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- tests/test_http.py | 4 ++-- tox.ini | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) 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/tox.ini b/tox.ini index e11d262..1108e75 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,7 @@ commands = tests.test_encoders \ tests.test_finders \ tests.test_functions \ + tests.test_http \ tests.test_intervals \ tests.test_metrics \ tests.test_paths \ @@ -58,4 +59,4 @@ basepython = python3.12 deps = .[sentry,cache,cairo] commands = - python -Wall -m unittest tests.test_render_cairo tests.test_render_glyph tests.test_http + python -Wall -m unittest tests.test_render_cairo tests.test_render_glyph From 6eb7c525d6e83e412e71d464952e3be4317119fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:34:35 +0000 Subject: [PATCH 08/10] Add separate GitHub Actions job for Cairo-dependent tests Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- .github/workflows/tests.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1dbab7c..fa0d0af 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,6 +49,26 @@ jobs: - name: Run tests with tox run: tox -e ${{ matrix.toxenv }} + test-cairo: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install Cairo dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev + + - name: Install tox + run: pip install tox + + - name: Run Cairo tests with tox + run: tox -e cairo + build: runs-on: ubuntu-latest steps: @@ -107,7 +127,7 @@ jobs: create-issue-on-failure: runs-on: ubuntu-latest - needs: [test, build, docs] + needs: [test, test-cairo, build, docs] if: always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && github.event_name == 'schedule' permissions: issues: write From b433830efb7a98db100c382cc796462a4dac4d2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:41:10 +0000 Subject: [PATCH 09/10] Add cairo tests to test matrix instead of separate job Co-authored-by: csett86 <1392875+csett86@users.noreply.github.com> --- .github/workflows/tests.yml | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fa0d0af..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 @@ -49,26 +51,6 @@ jobs: - name: Run tests with tox run: tox -e ${{ matrix.toxenv }} - test-cairo: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: 'pip' - - - name: Install Cairo dependencies - run: sudo apt-get update && sudo apt-get install -y libcairo2-dev - - - name: Install tox - run: pip install tox - - - name: Run Cairo tests with tox - run: tox -e cairo - build: runs-on: ubuntu-latest steps: @@ -127,7 +109,7 @@ jobs: create-issue-on-failure: runs-on: ubuntu-latest - needs: [test, test-cairo, build, docs] + needs: [test, build, docs] if: always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && github.event_name == 'schedule' permissions: issues: write From 91c229d5ffd5f3d0b9de6415800a33f6f2638a89 Mon Sep 17 00:00:00 2001 From: Christoph Settgast Date: Sun, 18 Jan 2026 21:13:56 +0100 Subject: [PATCH 10/10] install cairo always, just not use it --- docs/installation.rst | 13 +++---------- graphite_render/render/glyph.py | 2 +- pyproject.toml | 2 +- tests/test_render_cairo.py | 1 - tox.ini | 2 -- 5 files changed, 5 insertions(+), 15 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 543c4c3..457c620 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -14,7 +14,7 @@ Installing Graphite-Render requires: * 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. +* For image rendering (PNG/SVG/PDF graphs): Cairo. On debian/ubuntu, install the ``libcairo2`` package. Global installation ------------------- @@ -23,10 +23,6 @@ To install Graphite-Render globally on your system, run as root:: $ pip install graphite-render -For image rendering support (PNG/SVG/PDF graphs), install with the cairo extra:: - - $ pip install graphite-render[cairo] - Isolated installation (virtualenv) ---------------------------------- @@ -36,7 +32,7 @@ you can install it in a virtualenv. :: $ virtualenv /usr/share/python/graphite - $ /usr/share/python/graphite/bin/pip install graphite-render[cairo] + $ /usr/share/python/graphite/bin/pip install graphite-render .. _extras: @@ -47,9 +43,6 @@ When you install ``graphite-render``, all the dependencies for running a Graphit server that uses Whisper as a storage backend are installed. You can specify extra dependencies: -* For image rendering (PNG/SVG/PDF): ``pip install graphite-render[cairo]``. - You'll also need Cairo installed on your system (``libcairo2`` on debian/ubuntu). - * For `Sentry`_ integration: ``pip install graphite-render[sentry]``. * For `Cyanite`_ integration: ``pip install graphite-render[cyanite]``. @@ -65,4 +58,4 @@ extra dependencies: You can also combine several extra dependencies:: - $ pip install graphite-render[cairo,sentry,cyanite] + $ pip install graphite-render[sentry,cyanite] diff --git a/graphite_render/render/glyph.py b/graphite_render/render/glyph.py index 952f9bd..ac2fa6f 100644 --- a/graphite_render/render/glyph.py +++ b/graphite_render/render/glyph.py @@ -38,7 +38,7 @@ def _get_cairo(): except ImportError as e: raise ImportError( "cairocffi is required for image rendering but is not installed. " - "Install it with: pip install graphite-render[cairo]" + "Install it with: pip install cairocffi" ) from e return cairo diff --git a/pyproject.toml b/pyproject.toml index 48250a5..ffe3f2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ dependencies = [ "Flask", "PyYAML", + "cairocffi", "pyparsing>=3.0.0", "structlog", "tzlocal", @@ -47,7 +48,6 @@ cyanite = ["cyanite"] cache = ["flask-caching"] statsd = ["statsd"] docs = ["sphinx"] -cairo = ["cairocffi"] [project.urls] Homepage = "https://github.com/csett86/graphite-render" diff --git a/tests/test_render_cairo.py b/tests/test_render_cairo.py index b8e0f41..86e7052 100644 --- a/tests/test_render_cairo.py +++ b/tests/test_render_cairo.py @@ -1,6 +1,5 @@ # coding: utf-8 """Tests that require Cairo for image rendering (PNG/SVG/PDF).""" -import json import os import time diff --git a/tox.ini b/tox.ini index 1108e75..cb24b78 100644 --- a/tox.ini +++ b/tox.ini @@ -56,7 +56,5 @@ deps = [testenv:cairo] basepython = python3.12 -deps = - .[sentry,cache,cairo] commands = python -Wall -m unittest tests.test_render_cairo tests.test_render_glyph