xref: /curl/tests/http/scorecard.py (revision 0e0c8cdf)
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3#***************************************************************************
4#                                  _   _ ____  _
5#  Project                     ___| | | |  _ \| |
6#                             / __| | | | |_) | |
7#                            | (__| |_| |  _ <| |___
8#                             \___|\___/|_| \_\_____|
9#
10# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
11#
12# This software is licensed as described in the file COPYING, which
13# you should have received as part of this distribution. The terms
14# are also available at https://curl.se/docs/copyright.html.
15#
16# You may opt to use, copy, modify, merge, publish, distribute and/or sell
17# copies of the Software, and permit persons to whom the Software is
18# furnished to do so, under the terms of the COPYING file.
19#
20# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
21# KIND, either express or implied.
22#
23# SPDX-License-Identifier: curl
24#
25###########################################################################
26#
27import argparse
28import json
29import logging
30import os
31import re
32import sys
33from statistics import mean
34from typing import Dict, Any, Optional, List
35
36from testenv import Env, Httpd, CurlClient, Caddy, ExecResult, NghttpxQuic, RunProfile
37
38log = logging.getLogger(__name__)
39
40
41class ScoreCardError(Exception):
42    pass
43
44
45class ScoreCard:
46
47    def __init__(self, env: Env,
48                 protocol: str,
49                 server_descr: str,
50                 server_port: int,
51                 verbose: int,
52                 curl_verbose: int,
53                 download_parallel: int = 0,
54                 server_addr: Optional[str] = None):
55        self.verbose = verbose
56        self.env = env
57        self.protocol = protocol
58        self.server_descr = server_descr
59        self.server_addr = server_addr
60        self.server_port = server_port
61        self._silent_curl = not curl_verbose
62        self._download_parallel = download_parallel
63
64    def info(self, msg):
65        if self.verbose > 0:
66            sys.stderr.write(msg)
67            sys.stderr.flush()
68
69    def handshakes(self) -> Dict[str, Any]:
70        props = {}
71        sample_size = 5
72        self.info('TLS Handshake\n')
73        for authority in [
74            'curl.se', 'google.com', 'cloudflare.com', 'nghttp2.org'
75        ]:
76            self.info(f'  {authority}...')
77            props[authority] = {}
78            for ipv in ['ipv4', 'ipv6']:
79                self.info(f'{ipv}...')
80                c_samples = []
81                hs_samples = []
82                errors = []
83                for _ in range(sample_size):
84                    curl = CurlClient(env=self.env, silent=self._silent_curl,
85                                      server_addr=self.server_addr)
86                    args = [
87                        '--http3-only' if self.protocol == 'h3' else '--http2',
88                        f'--{ipv}', f'https://{authority}/'
89                    ]
90                    r = curl.run_direct(args=args, with_stats=True)
91                    if r.exit_code == 0 and len(r.stats) == 1:
92                        c_samples.append(r.stats[0]['time_connect'])
93                        hs_samples.append(r.stats[0]['time_appconnect'])
94                    else:
95                        errors.append(f'exit={r.exit_code}')
96                    props[authority][f'{ipv}-connect'] = mean(c_samples) \
97                        if len(c_samples) else -1
98                    props[authority][f'{ipv}-handshake'] = mean(hs_samples) \
99                        if len(hs_samples) else -1
100                    props[authority][f'{ipv}-errors'] = errors
101            self.info('ok.\n')
102        return props
103
104    def _make_docs_file(self, docs_dir: str, fname: str, fsize: int):
105        fpath = os.path.join(docs_dir, fname)
106        data1k = 1024*'x'
107        flen = 0
108        with open(fpath, 'w') as fd:
109            while flen < fsize:
110                fd.write(data1k)
111                flen += len(data1k)
112        return fpath
113
114    def setup_resources(self, server_docs: str,
115                        downloads: Optional[List[int]] = None):
116        for fsize in downloads:
117            label = self.fmt_size(fsize)
118            fname = f'score{label}.data'
119            self._make_docs_file(docs_dir=server_docs,
120                                 fname=fname, fsize=fsize)
121        self._make_docs_file(docs_dir=server_docs,
122                             fname='reqs10.data', fsize=10*1024)
123
124    def _check_downloads(self, r: ExecResult, count: int):
125        error = ''
126        if r.exit_code != 0:
127            error += f'exit={r.exit_code} '
128        if r.exit_code != 0 or len(r.stats) != count:
129            error += f'stats={len(r.stats)}/{count} '
130        fails = [s for s in r.stats if s['response_code'] != 200]
131        if len(fails) > 0:
132            error += f'{len(fails)} failed'
133        return error if len(error) > 0 else None
134
135    def transfer_single(self, url: str, count: int):
136        sample_size = count
137        count = 1
138        samples = []
139        errors = []
140        profiles = []
141        self.info('single...')
142        for _ in range(sample_size):
143            curl = CurlClient(env=self.env, silent=self._silent_curl,
144                              server_addr=self.server_addr)
145            r = curl.http_download(urls=[url], alpn_proto=self.protocol,
146                                   no_save=True, with_headers=False,
147                                   with_profile=True)
148            err = self._check_downloads(r, count)
149            if err:
150                errors.append(err)
151            else:
152                total_size = sum([s['size_download'] for s in r.stats])
153                samples.append(total_size / r.duration.total_seconds())
154                profiles.append(r.profile)
155        return {
156            'count': count,
157            'samples': sample_size,
158            'max-parallel': 1,
159            'speed': mean(samples) if len(samples) else -1,
160            'errors': errors,
161            'stats': RunProfile.AverageStats(profiles),
162        }
163
164    def transfer_serial(self, url: str, count: int):
165        sample_size = 1
166        samples = []
167        errors = []
168        profiles = []
169        url = f'{url}?[0-{count - 1}]'
170        self.info('serial...')
171        for _ in range(sample_size):
172            curl = CurlClient(env=self.env, silent=self._silent_curl,
173                              server_addr=self.server_addr)
174            r = curl.http_download(urls=[url], alpn_proto=self.protocol,
175                                   no_save=True,
176                                   with_headers=False, with_profile=True)
177            err = self._check_downloads(r, count)
178            if err:
179                errors.append(err)
180            else:
181                total_size = sum([s['size_download'] for s in r.stats])
182                samples.append(total_size / r.duration.total_seconds())
183                profiles.append(r.profile)
184        return {
185            'count': count,
186            'samples': sample_size,
187            'max-parallel': 1,
188            'speed': mean(samples) if len(samples) else -1,
189            'errors': errors,
190            'stats': RunProfile.AverageStats(profiles),
191        }
192
193    def transfer_parallel(self, url: str, count: int):
194        sample_size = 1
195        samples = []
196        errors = []
197        profiles = []
198        max_parallel = self._download_parallel if self._download_parallel > 0 else count
199        url = f'{url}?[0-{count - 1}]'
200        self.info('parallel...')
201        for _ in range(sample_size):
202            curl = CurlClient(env=self.env, silent=self._silent_curl,
203                              server_addr=self.server_addr)
204            r = curl.http_download(urls=[url], alpn_proto=self.protocol,
205                                   no_save=True,
206                                   with_headers=False,
207                                   with_profile=True,
208                                   extra_args=[
209                                       '--parallel',
210                                       '--parallel-max', str(max_parallel)
211                                   ])
212            err = self._check_downloads(r, count)
213            if err:
214                errors.append(err)
215            else:
216                total_size = sum([s['size_download'] for s in r.stats])
217                samples.append(total_size / r.duration.total_seconds())
218                profiles.append(r.profile)
219        return {
220            'count': count,
221            'samples': sample_size,
222            'max-parallel': max_parallel,
223            'speed': mean(samples) if len(samples) else -1,
224            'errors': errors,
225            'stats': RunProfile.AverageStats(profiles),
226        }
227
228    def download_url(self, label: str, url: str, count: int):
229        self.info(f'  {count}x{label}: ')
230        props = {
231            'single': self.transfer_single(url=url, count=10),
232        }
233        if count > 1:
234            props['serial'] = self.transfer_serial(url=url, count=count)
235            props['parallel'] = self.transfer_parallel(url=url, count=count)
236        self.info('ok.\n')
237        return props
238
239    def downloads(self, count: int, fsizes: List[int]) -> Dict[str, Any]:
240        scores = {}
241        for fsize in fsizes:
242            label = self.fmt_size(fsize)
243            fname = f'score{label}.data'
244            url = f'https://{self.env.domain1}:{self.server_port}/{fname}'
245            scores[label] = self.download_url(label=label, url=url, count=count)
246        return scores
247
248    def _check_uploads(self, r: ExecResult, count: int):
249        error = ''
250        if r.exit_code != 0:
251            error += f'exit={r.exit_code} '
252        if r.exit_code != 0 or len(r.stats) != count:
253            error += f'stats={len(r.stats)}/{count} '
254        fails = [s for s in r.stats if s['response_code'] != 200]
255        if len(fails) > 0:
256            error += f'{len(fails)} failed'
257        for f in fails:
258            error += f'[{f["response_code"]}]'
259        return error if len(error) > 0 else None
260
261    def upload_single(self, url: str, fpath: str, count: int):
262        sample_size = count
263        count = 1
264        samples = []
265        errors = []
266        profiles = []
267        self.info('single...')
268        for _ in range(sample_size):
269            curl = CurlClient(env=self.env, silent=self._silent_curl,
270                              server_addr=self.server_addr)
271            r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=self.protocol,
272                              with_headers=False, with_profile=True)
273            err = self._check_uploads(r, count)
274            if err:
275                errors.append(err)
276            else:
277                total_size = sum([s['size_upload'] for s in r.stats])
278                samples.append(total_size / r.duration.total_seconds())
279                profiles.append(r.profile)
280        return {
281            'count': count,
282            'samples': sample_size,
283            'max-parallel': 1,
284            'speed': mean(samples) if len(samples) else -1,
285            'errors': errors,
286            'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
287        }
288
289    def upload_serial(self, url: str, fpath: str, count: int):
290        sample_size = 1
291        samples = []
292        errors = []
293        profiles = []
294        url = f'{url}?id=[0-{count - 1}]'
295        self.info('serial...')
296        for _ in range(sample_size):
297            curl = CurlClient(env=self.env, silent=self._silent_curl,
298                              server_addr=self.server_addr)
299            r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=self.protocol,
300                              with_headers=False, with_profile=True)
301            err = self._check_uploads(r, count)
302            if err:
303                errors.append(err)
304            else:
305                total_size = sum([s['size_upload'] for s in r.stats])
306                samples.append(total_size / r.duration.total_seconds())
307                profiles.append(r.profile)
308        return {
309            'count': count,
310            'samples': sample_size,
311            'max-parallel': 1,
312            'speed': mean(samples) if len(samples) else -1,
313            'errors': errors,
314            'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
315        }
316
317    def upload_parallel(self, url: str, fpath: str, count: int):
318        sample_size = 1
319        samples = []
320        errors = []
321        profiles = []
322        max_parallel = count
323        url = f'{url}?id=[0-{count - 1}]'
324        self.info('parallel...')
325        for _ in range(sample_size):
326            curl = CurlClient(env=self.env, silent=self._silent_curl,
327                              server_addr=self.server_addr)
328            r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=self.protocol,
329                              with_headers=False, with_profile=True,
330                              extra_args=[
331                                   '--parallel',
332                                    '--parallel-max', str(max_parallel)
333                              ])
334            err = self._check_uploads(r, count)
335            if err:
336                errors.append(err)
337            else:
338                total_size = sum([s['size_upload'] for s in r.stats])
339                samples.append(total_size / r.duration.total_seconds())
340                profiles.append(r.profile)
341        return {
342            'count': count,
343            'samples': sample_size,
344            'max-parallel': max_parallel,
345            'speed': mean(samples) if len(samples) else -1,
346            'errors': errors,
347            'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
348        }
349
350    def upload_url(self, label: str, url: str, fpath: str, count: int):
351        self.info(f'  {count}x{label}: ')
352        props = {
353            'single': self.upload_single(url=url, fpath=fpath, count=10),
354        }
355        if count > 1:
356            props['serial'] = self.upload_serial(url=url, fpath=fpath, count=count)
357            props['parallel'] = self.upload_parallel(url=url, fpath=fpath, count=count)
358        self.info('ok.\n')
359        return props
360
361    def uploads(self, count: int, fsizes: List[int]) -> Dict[str, Any]:
362        scores = {}
363        url = f'https://{self.env.domain2}:{self.server_port}/curltest/put'
364        fpaths = {}
365        for fsize in fsizes:
366            label = self.fmt_size(fsize)
367            fname = f'upload{label}.data'
368            fpaths[label] = self._make_docs_file(docs_dir=self.env.gen_dir,
369                                                 fname=fname, fsize=fsize)
370
371        for label, fpath in fpaths.items():
372            scores[label] = self.upload_url(label=label, url=url, fpath=fpath,
373                                            count=count)
374        return scores
375
376    def do_requests(self, url: str, count: int, max_parallel: int = 1):
377        sample_size = 1
378        samples = []
379        errors = []
380        profiles = []
381        url = f'{url}?[0-{count - 1}]'
382        extra_args = [
383            '-w', '%{response_code},\\n',
384        ]
385        if max_parallel > 1:
386            extra_args.extend([
387               '--parallel', '--parallel-max', str(max_parallel)
388            ])
389        self.info(f'{max_parallel}...')
390        for _ in range(sample_size):
391            curl = CurlClient(env=self.env, silent=self._silent_curl,
392                              server_addr=self.server_addr)
393            r = curl.http_download(urls=[url], alpn_proto=self.protocol, no_save=True,
394                                   with_headers=False, with_profile=True,
395                                   with_stats=False, extra_args=extra_args)
396            if r.exit_code != 0:
397                errors.append(f'exit={r.exit_code}')
398            else:
399                samples.append(count / r.duration.total_seconds())
400                non_200s = 0
401                for line in r.stdout.splitlines():
402                    if not line.startswith('200,'):
403                        non_200s += 1
404                if non_200s > 0:
405                    errors.append(f'responses != 200: {non_200s}')
406            profiles.append(r.profile)
407        return {
408            'count': count,
409            'samples': sample_size,
410            'speed': mean(samples) if len(samples) else -1,
411            'errors': errors,
412            'stats': RunProfile.AverageStats(profiles),
413        }
414
415    def requests_url(self, url: str, count: int):
416        self.info(f'  {url}: ')
417        props = {}
418        # 300 is max in curl, see tool_main.h
419        for m in [1, 6, 25, 50, 100, 300]:
420            props[str(m)] = self.do_requests(url=url, count=count, max_parallel=m)
421        self.info('ok.\n')
422        return props
423
424    def requests(self, req_count) -> Dict[str, Any]:
425        url = f'https://{self.env.domain1}:{self.server_port}/reqs10.data'
426        return {
427            'count': req_count,
428            '10KB': self.requests_url(url=url, count=req_count),
429        }
430
431    def score(self,
432              handshakes: bool = True,
433              downloads: Optional[List[int]] = None,
434              download_count: int = 50,
435              uploads: Optional[List[int]] = None,
436              upload_count: int = 50,
437              req_count=5000,
438              requests: bool = True):
439        self.info(f"scoring {self.protocol} against {self.server_descr}\n")
440        p = {}
441        if self.protocol == 'h3':
442            p['name'] = 'h3'
443            if not self.env.have_h3_curl():
444                raise ScoreCardError('curl does not support HTTP/3')
445            for lib in ['ngtcp2', 'quiche', 'msh3', 'nghttp3']:
446                if self.env.curl_uses_lib(lib):
447                    p['implementation'] = lib
448                    break
449        elif self.protocol == 'h2':
450            p['name'] = 'h2'
451            if not self.env.have_h2_curl():
452                raise ScoreCardError('curl does not support HTTP/2')
453            for lib in ['nghttp2', 'hyper']:
454                if self.env.curl_uses_lib(lib):
455                    p['implementation'] = lib
456                    break
457        elif self.protocol == 'h1' or self.protocol == 'http/1.1':
458            proto = 'http/1.1'
459            p['name'] = proto
460            p['implementation'] = 'hyper' if self.env.curl_uses_lib('hyper')\
461                else 'native'
462        else:
463            raise ScoreCardError(f"unknown protocol: {self.protocol}")
464
465        if 'implementation' not in p:
466            raise ScoreCardError(f'did not recognized {p} lib')
467        p['version'] = Env.curl_lib_version(p['implementation'])
468
469        score = {
470            'curl': self.env.curl_fullname(),
471            'os': self.env.curl_os(),
472            'protocol': p,
473            'server': self.server_descr,
474        }
475        if handshakes:
476            score['handshakes'] = self.handshakes()
477        if downloads and len(downloads) > 0:
478            score['downloads'] = self.downloads(count=download_count,
479                                                fsizes=downloads)
480        if uploads and len(uploads) > 0:
481            score['uploads'] = self.uploads(count=upload_count,
482                                            fsizes=uploads)
483        if requests:
484            score['requests'] = self.requests(req_count=req_count)
485        self.info("\n")
486        return score
487
488    def fmt_ms(self, tval):
489        return f'{int(tval*1000)} ms' if tval >= 0 else '--'
490
491    def fmt_size(self, val):
492        if val >= (1024*1024*1024):
493            return f'{val / (1024*1024*1024):0.000f}GB'
494        elif val >= (1024 * 1024):
495            return f'{val / (1024*1024):0.000f}MB'
496        elif val >= 1024:
497            return f'{val / 1024:0.000f}KB'
498        else:
499            return f'{val:0.000f}B'
500
501    def fmt_mbs(self, val):
502        return f'{val/(1024*1024):0.000f} MB/s' if val >= 0 else '--'
503
504    def fmt_reqs(self, val):
505        return f'{val:0.000f} r/s' if val >= 0 else '--'
506
507    def print_score(self, score):
508        print(f'{score["protocol"]["name"].upper()} in {score["curl"]}')
509        if 'handshakes' in score:
510            print(f'{"Handshakes":<24} {"ipv4":25} {"ipv6":28}')
511            print(f'  {"Host":<17} {"Connect":>12} {"Handshake":>12} '
512                  f'{"Connect":>12} {"Handshake":>12}     {"Errors":<20}')
513            for key, val in score["handshakes"].items():
514                print(f'  {key:<17} {self.fmt_ms(val["ipv4-connect"]):>12} '
515                      f'{self.fmt_ms(val["ipv4-handshake"]):>12} '
516                      f'{self.fmt_ms(val["ipv6-connect"]):>12} '
517                      f'{self.fmt_ms(val["ipv6-handshake"]):>12}     '
518                      f'{"/".join(val["ipv4-errors"] + val["ipv6-errors"]):<20}'
519                      )
520        if 'downloads' in score:
521            # get the key names of all sizes and measurements made
522            sizes = []
523            measures = []
524            m_names = {}
525            mcol_width = 12
526            mcol_sw = 17
527            for sskey, ssval in score['downloads'].items():
528                if isinstance(ssval, str):
529                    continue
530                if sskey not in sizes:
531                    sizes.append(sskey)
532                for mkey, mval in score['downloads'][sskey].items():
533                    if mkey not in measures:
534                        measures.append(mkey)
535                        m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})'
536            print(f'Downloads from {score["server"]}')
537            print(f'  {"Size":>8}', end='')
538            for m in measures:
539                print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
540            print(f' {"Errors":^20}')
541
542            for size in score['downloads']:
543                size_score = score['downloads'][size]
544                print(f'  {size:>8}', end='')
545                errors = []
546                for val in size_score.values():
547                    if 'errors' in val:
548                        errors.extend(val['errors'])
549                for m in measures:
550                    if m in size_score:
551                        print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='')
552                        s = f'[{size_score[m]["stats"]["cpu"]:>.1f}%'\
553                            f'/{self.fmt_size(size_score[m]["stats"]["rss"])}]'
554                        print(f' {s:<{mcol_sw}}', end='')
555                    else:
556                        print(' '*mcol_width, end='')
557                if len(errors):
558                    print(f' {"/".join(errors):<20}')
559                else:
560                    print(f' {"-":^20}')
561
562        if 'uploads' in score:
563            # get the key names of all sizes and measurements made
564            sizes = []
565            measures = []
566            m_names = {}
567            mcol_width = 12
568            mcol_sw = 17
569            for sskey, ssval in score['uploads'].items():
570                if isinstance(ssval, str):
571                    continue
572                if sskey not in sizes:
573                    sizes.append(sskey)
574                for mkey, mval in ssval.items():
575                    if mkey not in measures:
576                        measures.append(mkey)
577                        m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})'
578
579            print(f'Uploads to {score["server"]}')
580            print(f'  {"Size":>8}', end='')
581            for m in measures:
582                print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
583            print(f' {"Errors":^20}')
584
585            for size in sizes:
586                size_score = score['uploads'][size]
587                print(f'  {size:>8}', end='')
588                errors = []
589                for val in size_score.values():
590                    if 'errors' in val:
591                        errors.extend(val['errors'])
592                for m in measures:
593                    if m in size_score:
594                        print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='')
595                        stats = size_score[m]["stats"]
596                        if 'cpu' in stats:
597                            s = f'[{stats["cpu"]:>.1f}%/{self.fmt_size(stats["rss"])}]'
598                        else:
599                            s = '[???/???]'
600                        print(f' {s:<{mcol_sw}}', end='')
601                    else:
602                        print(' '*mcol_width, end='')
603                if len(errors):
604                    print(f' {"/".join(errors):<20}')
605                else:
606                    print(f' {"-":^20}')
607
608        if 'requests' in score:
609            sizes = []
610            measures = []
611            m_names = {}
612            mcol_width = 9
613            mcol_sw = 13
614            for sskey, ssval in score['requests'].items():
615                if isinstance(ssval, (str, int)):
616                    continue
617                if sskey not in sizes:
618                    sizes.append(sskey)
619                for mkey in score['requests'][sskey]:
620                    if mkey not in measures:
621                        measures.append(mkey)
622                        m_names[mkey] = f'{mkey}'
623
624            print('Requests (max parallel) to {score["server"]}')
625            print(f'  {"Size":>6} {"Reqs":>6}', end='')
626            for m in measures:
627                print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
628            print(f' {"Errors":^10}')
629
630            for size in sizes:
631                size_score = score['requests'][size]
632                count = score['requests']['count']
633                print(f'  {size:>6} {count:>6}', end='')
634                errors = []
635                for val in size_score.values():
636                    if 'errors' in val:
637                        errors.extend(val['errors'])
638                for m in measures:
639                    if m in size_score:
640                        print(f' {self.fmt_reqs(size_score[m]["speed"]):>{mcol_width}}', end='')
641                        s = f'[{size_score[m]["stats"]["cpu"]:>.1f}%'\
642                            f'/{self.fmt_size(size_score[m]["stats"]["rss"])}]'
643                        print(f' {s:<{mcol_sw}}', end='')
644                    else:
645                        print(' '*mcol_width, end='')
646                if len(errors):
647                    print(f' {"/".join(errors):<10}')
648                else:
649                    print(f' {"-":^10}')
650
651
652def parse_size(s):
653    m = re.match(r'(\d+)(mb|kb|gb)?', s, re.IGNORECASE)
654    if m is None:
655        raise Exception(f'unrecognized size: {s}')
656    size = int(m.group(1))
657    if not m.group(2):
658        pass
659    elif m.group(2).lower() == 'kb':
660        size *= 1024
661    elif m.group(2).lower() == 'mb':
662        size *= 1024 * 1024
663    elif m.group(2).lower() == 'gb':
664        size *= 1024 * 1024 * 1024
665    return size
666
667
668def main():
669    parser = argparse.ArgumentParser(prog='scorecard', description="""
670        Run a range of tests to give a scorecard for a HTTP protocol
671        'h3' or 'h2' implementation in curl.
672        """)
673    parser.add_argument("-v", "--verbose", action='count', default=1,
674                        help="log more output on stderr")
675    parser.add_argument("-j", "--json", action='store_true',
676                        default=False, help="print json instead of text")
677    parser.add_argument("-H", "--handshakes", action='store_true',
678                        default=False, help="evaluate handshakes only")
679    parser.add_argument("-d", "--downloads", action='store_true',
680                        default=False, help="evaluate downloads")
681    parser.add_argument("--download", action='append', type=str,
682                        default=None, help="evaluate download size")
683    parser.add_argument("--download-count", action='store', type=int,
684                        default=50, help="perform that many downloads")
685    parser.add_argument("--download-parallel", action='store', type=int,
686                        default=0, help="perform that many downloads in parallel (default all)")
687    parser.add_argument("-u", "--uploads", action='store_true',
688                        default=False, help="evaluate uploads")
689    parser.add_argument("--upload", action='append', type=str,
690                        default=None, help="evaluate upload size")
691    parser.add_argument("--upload-count", action='store', type=int,
692                        default=50, help="perform that many uploads")
693    parser.add_argument("-r", "--requests", action='store_true',
694                        default=False, help="evaluate requests")
695    parser.add_argument("--request-count", action='store', type=int,
696                        default=5000, help="perform that many requests")
697    parser.add_argument("--httpd", action='store_true', default=False,
698                        help="evaluate httpd server only")
699    parser.add_argument("--caddy", action='store_true', default=False,
700                        help="evaluate caddy server only")
701    parser.add_argument("--curl-verbose", action='store_true',
702                        default=False, help="run curl with `-v`")
703    parser.add_argument("protocol", default='h2', nargs='?',
704                        help="Name of protocol to score")
705    parser.add_argument("--start-only", action='store_true', default=False,
706                        help="only start the servers")
707    parser.add_argument("--remote", action='store', type=str,
708                        default=None, help="score against the remote server at <ip>:<port>")
709    args = parser.parse_args()
710
711    if args.verbose > 0:
712        console = logging.StreamHandler()
713        console.setLevel(logging.INFO)
714        console.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
715        logging.getLogger('').addHandler(console)
716
717    protocol = args.protocol
718    handshakes = True
719    downloads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024]
720    if args.download is not None:
721        downloads = []
722        for x in args.download:
723            downloads.extend([parse_size(s) for s in x.split(',')])
724
725    uploads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024]
726    if args.upload is not None:
727        uploads = []
728        for x in args.upload:
729            uploads.extend([parse_size(s) for s in x.split(',')])
730
731    requests = True
732    if args.downloads or args.uploads or args.requests or args.handshakes:
733        handshakes = args.handshakes
734        if not args.downloads:
735            downloads = None
736        if not args.uploads:
737            uploads = None
738        requests = args.requests
739
740    test_httpd = protocol != 'h3'
741    test_caddy = True
742    if args.caddy or args.httpd:
743        test_caddy = args.caddy
744        test_httpd = args.httpd
745
746    rv = 0
747    env = Env()
748    env.setup()
749    env.test_timeout = None
750    httpd = None
751    nghttpx = None
752    caddy = None
753    try:
754        cards = []
755
756        if args.remote:
757            m = re.match(r'^(.+):(\d+)$', args.remote)
758            if m is None:
759                raise ScoreCardError(f'unable to parse ip:port from --remote {args.remote}')
760            test_httpd = False
761            test_caddy = False
762            remote_addr = m.group(1)
763            remote_port = int(m.group(2))
764            card = ScoreCard(env=env,
765                             protocol=protocol,
766                             server_descr=f'Server at {args.remote}',
767                             server_addr=remote_addr,
768                             server_port=remote_port,
769                             verbose=args.verbose, curl_verbose=args.curl_verbose,
770                             download_parallel=args.download_parallel)
771            cards.append(card)
772
773        if test_httpd:
774            httpd = Httpd(env=env)
775            assert httpd.exists(), \
776                f'httpd not found: {env.httpd}'
777            httpd.clear_logs()
778            server_docs = httpd.docs_dir
779            assert httpd.start()
780            if protocol == 'h3':
781                nghttpx = NghttpxQuic(env=env)
782                nghttpx.clear_logs()
783                assert nghttpx.start()
784                server_descr = f'nghttpx: https:{env.h3_port} [backend httpd: {env.httpd_version()}, https:{env.https_port}]'
785                server_port = env.h3_port
786            else:
787                server_descr = f'httpd: {env.httpd_version()}, http:{env.http_port} https:{env.https_port}'
788                server_port = env.https_port
789            card = ScoreCard(env=env,
790                             protocol=protocol,
791                             server_descr=server_descr,
792                             server_port=server_port,
793                             verbose=args.verbose, curl_verbose=args.curl_verbose,
794                             download_parallel=args.download_parallel)
795            card.setup_resources(server_docs, downloads)
796            cards.append(card)
797
798        if test_caddy and env.caddy:
799            backend = ''
800            if uploads and httpd is None:
801                backend = f' [backend httpd: {env.httpd_version()}, http:{env.http_port} https:{env.https_port}]'
802                httpd = Httpd(env=env)
803                assert httpd.exists(), \
804                    f'httpd not found: {env.httpd}'
805                httpd.clear_logs()
806                assert httpd.start()
807            caddy = Caddy(env=env)
808            caddy.clear_logs()
809            assert caddy.start()
810            server_descr = f'Caddy: {env.caddy_version()}, http:{env.caddy_http_port} https:{env.caddy_https_port}{backend}'
811            server_port = caddy.port
812            server_docs = caddy.docs_dir
813            card = ScoreCard(env=env,
814                             protocol=protocol,
815                             server_descr=server_descr,
816                             server_port=server_port,
817                             verbose=args.verbose, curl_verbose=args.curl_verbose,
818                             download_parallel=args.download_parallel)
819            card.setup_resources(server_docs, downloads)
820            cards.append(card)
821
822        if args.start_only:
823            print('started servers:')
824            for card in cards:
825                print(f'{card.server_descr}')
826            sys.stderr.write('press [RETURN] to finish')
827            sys.stderr.flush()
828            sys.stdin.readline()
829        else:
830            for card in cards:
831                score = card.score(handshakes=handshakes,
832                                   downloads=downloads,
833                                   download_count=args.download_count,
834                                   uploads=uploads,
835                                   upload_count=args.upload_count,
836                                   req_count=args.request_count,
837                                   requests=requests)
838                if args.json:
839                    print(json.JSONEncoder(indent=2).encode(score))
840                else:
841                    card.print_score(score)
842
843    except ScoreCardError as ex:
844        sys.stderr.write(f"ERROR: {ex}\n")
845        rv = 1
846    except KeyboardInterrupt:
847        log.warning("aborted")
848        rv = 1
849    finally:
850        if caddy:
851            caddy.stop()
852        if nghttpx:
853            nghttpx.stop(wait_dead=False)
854        if httpd:
855            httpd.stop()
856    sys.exit(rv)
857
858
859if __name__ == "__main__":
860    main()
861