├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── approximation.ipynb ├── cm_factor.sage ├── cm_factor_python.ipynb ├── coloredlogs ├── __init__.py ├── cli.py ├── converter │ ├── __init__.py │ └── colors.py ├── demo.py ├── syslog.py └── tests.py ├── data ├── .gitkeep ├── semifactor_15.json └── semifactor_17.json ├── division_poly.ipynb ├── experiments.sage └── humanfriendly ├── __init__.py ├── cli.py ├── compat.py ├── decorators.py ├── prompts.py ├── sphinx.py ├── tables.py ├── terminal.py ├── testing.py ├── tests.py ├── text.py └── usage.py /.gitattributes: -------------------------------------------------------------------------------- 1 | data/ filter=lfs diff=lfs merge=lfs -text 2 | data/semifactor_15.json filter=lfs diff=lfs merge=lfs -text 3 | data/semifactor_17.json filter=lfs diff=lfs merge=lfs -text 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 CRoCS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CM-based factorization 2 | Complex multiplication based factorization. 3 | This repository contains proof of concept factorization code and scripts used in experiments. 4 | 5 | ## Proof of concept script usage 6 | 7 | Generating CM primes: 8 | 9 | ```bash 10 | sage cm_factor.sage --action generate --prime-bits 64 11 | 16344322632527439553 12 | ``` 13 | 14 | CM factorization: 15 | 16 | ```bash 17 | sage cm_factor.sage -N 158697752795669080171615843390068686677 -D 11 18 | Factorization of N: 158697752795669080171615843390068686677 is: 19 | 14793660019451035033 * 10727416514034371869 20 | ``` 21 | 22 | ## Advanced usage script 23 | 24 | More complex script that was used to run experiments covered in the paper is `experiments.sage`. 25 | It covers distributed computation of the factorization, class number sampling, batch BCD computation, 26 | Hilbert polynomial generation, primorial D computation, benchmarking. 27 | 28 | 29 | ## Dependencies 30 | 31 | Sage 8 is required. Included packages `coloredlogs`, `humanfriendly` are optional. 32 | 33 | -------------------------------------------------------------------------------- /cm_factor.sage: -------------------------------------------------------------------------------- 1 | # packages (optional): 2 | # sage --pip install coloredlogs 3 | 4 | import time 5 | import argparse 6 | import sys 7 | import logging 8 | import traceback 9 | 10 | from sage.misc.prandom import randrange 11 | from sage.parallel.decorate import fork 12 | 13 | 14 | debug = False 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | try: 19 | import coloredlogs 20 | coloredlogs.CHROOT_FILES = [] 21 | coloredlogs.install(level=logging.DEBUG, use_chroot=False) 22 | except: 23 | pass 24 | 25 | 26 | class AlgException(Exception): 27 | pass 28 | 29 | 30 | class NotInvertibleException(AlgException): 31 | pass 32 | 33 | 34 | class NotFactoredException(AlgException): 35 | pass 36 | 37 | 38 | class FactorRes(object): 39 | def __init__(self, r=None, c=None, u=None, a=None, th=None, tq=None): 40 | self.r = r 41 | self.c = c 42 | self.u = u 43 | self.a = a 44 | self.time_hilbert = th 45 | self.time_q = tq 46 | self.time_a = None 47 | self.time_last_div = None 48 | self.time_last_gcd = None 49 | self.time_last_nrm = None 50 | self.time_total = 0 51 | self.time_agg_div = 0 52 | self.time_agg_gcd = 0 53 | self.time_agg_nrm = 0 54 | self.time_qinv_char_poly = 0 55 | self.time_qinv_xgcd = 0 56 | self.time_qinv_res = 0 57 | self.rand_elem = None 58 | self.fact_timeout = None 59 | self.out_of_time = False 60 | self.use_quinv2 = False 61 | self.use_cheng = False 62 | 63 | 64 | def is_undef(result): 65 | return result in ['NO DATA (timed out)', 'NO DATA', 'INVALID DATA', 'INVALID DATA ', None] 66 | 67 | 68 | def class_number(d): 69 | k = QuadraticField(-d, 'x') 70 | return k.class_number() 71 | 72 | 73 | def random_cm_prime_sub(thresh_l, thresh_h, rstart, rstop, D): 74 | while True: 75 | s = -1 76 | 77 | # Better is sqrt distribution as with uniform on i we get skew on i^2 78 | while True: 79 | s = randrange(thresh_l, thresh_h) 80 | s = int(isqrt((4r * s - 1r) / D)) # memory leak: s = int(sqrt((4 * s - 1) / D)) 81 | if s & 1r == 1r and s >= rstart and s <= rstop: 82 | break 83 | 84 | p = int((D * s * s + 1r) / 4r) 85 | if p > thresh_h or p < thresh_l: 86 | continue 87 | if is_prime(p): 88 | return p 89 | 90 | 91 | def generateCMprime(D, bits, verb = 1): 92 | if D % 8 != 3: 93 | raise ValueError('D must be congruent to 3 modulo 8') 94 | 95 | p = None 96 | thresh_l = 1 << (bits - 1) 97 | thresh_h = (1 << bits) - 1 98 | 99 | # Exact bounds to cover the whole interval 100 | rstart = int(isqrt((4r*thresh_l-1r)/D)) 101 | rstop = int(isqrt((4r*thresh_h-1r)/D))+1r 102 | 103 | while True: 104 | p = random_cm_prime_sub(thresh_l, thresh_h, rstart, rstop, D) 105 | if p: 106 | return p 107 | 108 | 109 | def xgcd(f, g, N=1): 110 | toswap = False 111 | if f.degree() < g.degree(): 112 | toswap = True 113 | f, g = g, f 114 | r_i = f 115 | r_i_plus = g 116 | r_i_plus_plus = f 117 | 118 | s_i, s_i_plus = 1, 0 119 | t_i, t_i_plus = 0, 1 120 | 121 | while (True): 122 | lc = r_i.lc().lift() 123 | lc *= r_i_plus.lc().lift() 124 | lc *= r_i_plus_plus.lc().lift() 125 | divisor = gcd(lc, N) 126 | if divisor > 1: 127 | print('Divisisor of %s is %s'%(N,divisor)) 128 | return divisor, None, None 129 | 130 | q = r_i // r_i_plus 131 | s_i_plus_plus = s_i - q * s_i_plus 132 | t_i_plus_plus = t_i - q * t_i_plus 133 | r_i_plus_plus = r_i - q * r_i_plus 134 | if r_i_plus.degree() <= r_i_plus_plus.degree() or r_i_plus_plus.degree() == -1: 135 | if toswap == True: 136 | assert (r_i_plus == s_i_plus * f + t_i_plus * g) 137 | return r_i_plus, t_i_plus, s_i_plus 138 | else: 139 | assert (r_i_plus == s_i_plus * f + t_i_plus * g) 140 | return r_i_plus, s_i_plus, t_i_plus, 141 | r_i, r_i_plus = r_i_plus, r_i_plus_plus 142 | s_i, s_i_plus = s_i_plus, s_i_plus_plus 143 | t_i, t_i_plus = t_i_plus, t_i_plus_plus 144 | check_res = r_i == s_i * f + t_i * g 145 | if not check_res: 146 | logger.error('Assertion error: %s, %s != %s * %s + %s * %s' % (check_res, r_i, s_i, f, t_i, g)) 147 | raise ValueError('xgcd assertion error') 148 | 149 | 150 | def Qinverse(Q, a, N): 151 | """ 152 | Q is a quotient ring of Z_N[x], a is the element to be inverted 153 | :param Q: 154 | :param a: 155 | :return: 156 | """ 157 | j = Q.gens()[0] 158 | deg = j.charpoly('X').degree() 159 | A = Q(a).matrix() 160 | det_a = det(A) 161 | logger.debug('DetA: %s' % det_a) 162 | 163 | factor = gcd(int(det_a), N) 164 | if factor!=1: # a is not invertible 165 | raise ZeroDivisionError(a) 166 | 167 | else: 168 | Y = vector([1] + (deg-1)*[0]) 169 | X = A.solve_left(Y) 170 | jvec = vector([j^i for i in [0..deg-1]]) 171 | Xj = jvec*X 172 | return Xj 173 | 174 | 175 | def Qinverse2 (Hx, a, N, time_res): 176 | ts = time.time() 177 | r,s,t = xgcd(a.lift(), Hx, N) 178 | txgcd = time.time() 179 | if (s,t) == (None, None): 180 | res = r, 0 181 | else: 182 | rinv = r[0]^(-1) 183 | res = 1, s * rinv 184 | tres = time.time() 185 | 186 | time_res.time_qinv_char_poly = 0 187 | time_res.time_qinv_xgcd = txgcd - ts 188 | time_res.time_qinv_res = tres - txgcd 189 | return res 190 | 191 | 192 | def CMfactor(D, N, verb = 1, ctries=10, utries=10, fact_time=None, use_quinv2=False, use_cheng=False): 193 | """ 194 | Try to factor N with respect to D, with ctries values of c and utries values of u 195 | """ 196 | ts = time.time() 197 | Hx = hilbert_class_polynomial(-D) 198 | tth = time.time() 199 | if verb == 1: 200 | logger.debug('Hilbert polynomial computed for -%s!' % D) 201 | 202 | res = FactorRes() 203 | res.use_quinv2 = use_quinv2 204 | res.use_cheng = use_cheng 205 | 206 | ZN = Integers(N) 207 | R. = PolynomialRing(ZN) 208 | 209 | ttq = time.time() 210 | try: 211 | if use_quinv2: 212 | Hx = R(Hx) 213 | Q. = QuotientRing(R, R.ideal(Hx)) 214 | gcd, inverse = Qinverse2(Hx, 1728 - j, N, res) 215 | if gcd == 1: 216 | a = Q(j * inverse) 217 | 218 | else: 219 | Q. = ZN.extension(Hx) 220 | a = j * Qinverse(Q, 1728 - j, N) 221 | 222 | except ZeroDivisionError as noninv: 223 | logger.warning("is not invertible in Q! %s" % noninv) 224 | raise NotInvertibleException() 225 | if gcd != 1: 226 | exit(-1) 227 | if verb == 1: 228 | logger.debug('Q constructed') 229 | logger.debug('a computed: %s' % a) 230 | 231 | tta = time.time() 232 | res.time_agg_div = 0 233 | res.time_agg_gcd = 0 234 | res.time_hilbert = tth - ts 235 | res.time_q = ttq - tth 236 | res.time_a = tta - ttq 237 | res.a = None 238 | 239 | core_fnc = CMfactor_core 240 | if fact_time: 241 | time_left = fact_time - (tta - ts) 242 | res.fact_timeout = time_left 243 | core_fnc = fork(CMfactor_core, time_left) 244 | cres = core_fnc(N, ctries, utries, a, Q, ZN, Hx, res, use_cheng=use_cheng) 245 | if is_undef(cres): 246 | res.out_of_time = True 247 | else: 248 | res = cres 249 | 250 | tdone = time.time() 251 | res.time_total = tdone - ts 252 | return res 253 | 254 | 255 | def CMfactor_core(N, ctries, utries, a, Q, ZN, Hx, res, use_cheng=False): 256 | is_done = False 257 | 258 | # We prove this takes only one iteration 259 | for c in [1..ctries]: 260 | E = EllipticCurve(Q, [0, 0, 0, 3 * a * c ^ 2, 2 * a * c ^ 3]) 261 | 262 | # expected number of u iterations: cn^2 / (cn^2 - 1) 263 | for u in [1..utries]: 264 | 265 | # Division polynomial is the most expensive part here 266 | tcs = time.time() 267 | rand_elem = ZN.random_element() 268 | res.rand_elem = int(rand_elem) 269 | 270 | w = E.division_polynomial(N, Q(rand_elem), two_torsion_multiplicity=0) 271 | ttdiv = time.time() 272 | logger.debug('Division polynomial done') 273 | 274 | if use_cheng: 275 | poly_gcd = xgcd(w.lift(), Hx, N)[0] 276 | ttnrm = time.time() 277 | r = gcd(ZZ(poly_gcd), N) 278 | ttgcd = time.time() 279 | 280 | else: 281 | nrm = w.norm() 282 | ttnrm = time.time() 283 | r = gcd(nrm, N) 284 | ttgcd = time.time() 285 | 286 | res.time_agg_div += ttdiv - tcs 287 | res.time_agg_gcd += ttgcd - ttdiv 288 | res.time_agg_nrm += ttnrm - ttdiv 289 | res.c = int(c) 290 | res.u = int(u) 291 | res.time_last_div = ttdiv - tcs 292 | res.time_last_gcd = ttgcd - ttdiv 293 | res.time_last_nrm = ttnrm - ttdiv 294 | 295 | if r > 1 and r != N: 296 | res.r = int(r) 297 | logger.debug('A factor of N: %s' % r) 298 | logger.debug('c: %s, u: %s' % (c, u)) 299 | is_done = True 300 | break 301 | 302 | else: 303 | logger.info('u failed: %s, next_attempt' % u) 304 | 305 | if is_done: 306 | break 307 | 308 | return res 309 | 310 | 311 | def work_generate(args): 312 | logger.debug('Generating CM prime with D=%s, bits=%s' % (args.disc, args.prime_bits)) 313 | p = generateCMprime(args.disc, args.prime_bits) 314 | print(p) 315 | 316 | 317 | def work_factor(args): 318 | disc = args.disc 319 | class_num = class_number(disc) 320 | sys.setrecursionlimit(50000) # for the computation of division polynomials 321 | 322 | success = False 323 | ts = time.time() 324 | logger.debug('D: %s, class_num: %s' % (disc, class_num)) 325 | 326 | try: 327 | factor_timeout = args.timeout if args.timeout > 0 else None 328 | res = CMfactor(disc, args.mod, 1, fact_time=factor_timeout, use_quinv2=args.qinv2, use_cheng=args.cheng) 329 | res = -1 if is_undef(res) else res 330 | result = res if res and not isinstance(res, int) else None 331 | 332 | time_total = time.time() - ts 333 | logger.debug('Total time elapsed: %s' % time_total) 334 | 335 | if result.r > 1: 336 | if args.mod % result.r != 0: 337 | raise ValueError('Found result is invalid') 338 | 339 | q = args.mod // result.r 340 | success = True 341 | print("Factorization of N: %s is: \n%s * %s" % (args.mod, result.r, q)) 342 | 343 | except Exception as e: 344 | logger.warning('Exception: %s' % e) 345 | if args.debug: 346 | traceback.print_exc() 347 | 348 | if not success: 349 | print('Factorization failed') 350 | 351 | 352 | def main(): 353 | parser = argparse.ArgumentParser(description='CM factorization script') 354 | parser.add_argument('--action', dest='action', action="store", default="factor", 355 | help='Action to perform, options: factor, generate') 356 | 357 | parser.add_argument('--modulus', '-N', dest='mod', action='store', type=int, default=158697752795669080171615843390068686677, 358 | help='Modulus to factorize') 359 | parser.add_argument('--disc', '-D', dest='disc', action='store', type=int, default=11, 360 | help='D, the discriminant to compute factorization for') 361 | parser.add_argument('--prime-bits', dest='prime_bits', action='store', type=int, default=256, 362 | help='Number of prime bits to generate') 363 | 364 | parser.add_argument('--timeout', dest='timeout', action="store", type=int, default=4*60, 365 | help='Number of seconds for the factorization job, negative for no timeout') 366 | parser.add_argument('--qinv2', dest='qinv2', default=1, type=int, 367 | help='Use optimized inversion algorithm (enabled by default)') 368 | parser.add_argument('--cheng', dest='cheng', default=1, type=int, 369 | help='Use Cheng xgcd instead of norms (enabled by default)') 370 | 371 | parser.add_argument('--debug', dest='debug', action='store_const', const=True, default=False, 372 | help='Debugging enabled') 373 | 374 | args = parser.parse_args() 375 | if args.action is None or args.action == 'factor': 376 | work_factor(args) 377 | elif args.action == 'generate': 378 | work_generate(args) 379 | else: 380 | raise ValueError('Unknown action: %s' % args.action) 381 | 382 | 383 | if __name__ == "__main__": 384 | main() 385 | 386 | -------------------------------------------------------------------------------- /coloredlogs/cli.py: -------------------------------------------------------------------------------- 1 | # Command line interface for the coloredlogs package. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: December 15, 2017 5 | # URL: https://coloredlogs.readthedocs.io 6 | 7 | """ 8 | Usage: coloredlogs [OPTIONS] [ARGS] 9 | 10 | The coloredlogs program provides a simple command line interface for the Python 11 | package by the same name. 12 | 13 | Supported options: 14 | 15 | -c, --convert, --to-html 16 | 17 | Capture the output of an external command (given by the positional 18 | arguments) and convert ANSI escape sequences in the output to HTML. 19 | 20 | If the `coloredlogs' program is attached to an interactive terminal it will 21 | write the generated HTML to a temporary file and open that file in a web 22 | browser, otherwise the generated HTML will be written to standard output. 23 | 24 | This requires the `script' program to fake the external command into 25 | thinking that it's attached to an interactive terminal (in order to enable 26 | output of ANSI escape sequences). 27 | 28 | If the command didn't produce any output then no HTML will be produced on 29 | standard output, this is to avoid empty emails from cron jobs. 30 | 31 | -d, --demo 32 | 33 | Perform a simple demonstration of the coloredlogs package to show the 34 | colored logging on an interactive terminal. 35 | 36 | -h, --help 37 | 38 | Show this message and exit. 39 | """ 40 | 41 | # Standard library modules. 42 | import functools 43 | import getopt 44 | import logging 45 | import sys 46 | import tempfile 47 | import webbrowser 48 | 49 | # External dependencies. 50 | from humanfriendly.terminal import connected_to_terminal, output, usage, warning 51 | 52 | # Modules included in our package. 53 | from coloredlogs.converter import capture, convert 54 | from coloredlogs.demo import demonstrate_colored_logging 55 | 56 | # Initialize a logger for this module. 57 | logger = logging.getLogger(__name__) 58 | 59 | 60 | def main(): 61 | """Command line interface for the ``coloredlogs`` program.""" 62 | actions = [] 63 | try: 64 | # Parse the command line arguments. 65 | options, arguments = getopt.getopt(sys.argv[1:], 'cdh', [ 66 | 'convert', 'to-html', 'demo', 'help', 67 | ]) 68 | # Map command line options to actions. 69 | for option, value in options: 70 | if option in ('-c', '--convert', '--to-html'): 71 | actions.append(functools.partial(convert_command_output, *arguments)) 72 | arguments = [] 73 | elif option in ('-d', '--demo'): 74 | actions.append(demonstrate_colored_logging) 75 | elif option in ('-h', '--help'): 76 | usage(__doc__) 77 | return 78 | else: 79 | assert False, "Programming error: Unhandled option!" 80 | if not actions: 81 | usage(__doc__) 82 | return 83 | except Exception as e: 84 | warning("Error: %s", e) 85 | sys.exit(1) 86 | for function in actions: 87 | function() 88 | 89 | 90 | def convert_command_output(*command): 91 | """ 92 | Command line interface for ``coloredlogs --to-html``. 93 | 94 | Takes a command (and its arguments) and runs the program under ``script`` 95 | (emulating an interactive terminal), intercepts the output of the command 96 | and converts ANSI escape sequences in the output to HTML. 97 | """ 98 | captured_output = capture(command) 99 | converted_output = convert(captured_output) 100 | if connected_to_terminal(): 101 | fd, temporary_file = tempfile.mkstemp(suffix='.html') 102 | with open(temporary_file, 'w') as handle: 103 | handle.write(converted_output) 104 | webbrowser.open(temporary_file) 105 | elif captured_output and not captured_output.isspace(): 106 | output(converted_output) 107 | -------------------------------------------------------------------------------- /coloredlogs/converter/__init__.py: -------------------------------------------------------------------------------- 1 | # Program to convert text with ANSI escape sequences to HTML. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: January 17, 2018 5 | # URL: https://coloredlogs.readthedocs.io 6 | 7 | """Convert text with ANSI escape sequences to HTML.""" 8 | 9 | # Standard library modules. 10 | import codecs 11 | import os 12 | import pipes 13 | import re 14 | import subprocess 15 | import tempfile 16 | 17 | # External dependencies. 18 | from humanfriendly.terminal import ( 19 | ANSI_CSI, 20 | ANSI_TEXT_STYLES, 21 | clean_terminal_output, 22 | output, 23 | ) 24 | 25 | # Modules included in our package. 26 | from coloredlogs.converter.colors import ( 27 | BRIGHT_COLOR_PALETTE, 28 | EIGHT_COLOR_PALETTE, 29 | EXTENDED_COLOR_PALETTE, 30 | ) 31 | 32 | # Compiled regular expression that matches leading spaces (indentation). 33 | INDENT_PATTERN = re.compile('^ +', re.MULTILINE) 34 | 35 | # Compiled regular expression that matches a tag followed by a space at the start of a line. 36 | TAG_INDENT_PATTERN = re.compile('^(<[^>]+>) ', re.MULTILINE) 37 | 38 | # Compiled regular expression that matches strings we want to convert. Used to 39 | # separate all special strings and literal output in a single pass (this allows 40 | # us to properly encode the output without resorting to nasty hacks). 41 | TOKEN_PATTERN = re.compile(''' 42 | # Wrap the pattern in a capture group so that re.split() includes the 43 | # substrings that match the pattern in the resulting list of strings. 44 | ( 45 | # Match URLs with supported schemes and domain names. 46 | (?: https?:// | www\\. ) 47 | # Scan until the end of the URL by matching non-whitespace characters 48 | # that are also not escape characters. 49 | [^\s\x1b]+ 50 | # Alternatively ... 51 | | 52 | # Match (what looks like) ANSI escape sequences. 53 | \x1b \[ .*? m 54 | ) 55 | ''', re.UNICODE | re.VERBOSE) 56 | 57 | 58 | def capture(command, encoding='UTF-8'): 59 | """ 60 | Capture the output of an external command as if it runs in an interactive terminal. 61 | 62 | :param command: The command name and its arguments (a list of strings). 63 | :param encoding: The encoding to use to decode the output (a string). 64 | :returns: The output of the command. 65 | 66 | This function runs an external command under ``script`` (emulating an 67 | interactive terminal) to capture the output of the command as if it was 68 | running in an interactive terminal (including ANSI escape sequences). 69 | """ 70 | with open(os.devnull, 'wb') as dev_null: 71 | # We start by invoking the `script' program in a form that is supported 72 | # by the Linux implementation [1] but fails command line validation on 73 | # the MacOS (BSD) implementation [2]: The command is specified using 74 | # the -c option and the typescript file is /dev/null. 75 | # 76 | # [1] http://man7.org/linux/man-pages/man1/script.1.html 77 | # [2] https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man1/script.1.html 78 | command_line = ['script', '-qc', ' '.join(map(pipes.quote, command)), '/dev/null'] 79 | script = subprocess.Popen(command_line, stdout=subprocess.PIPE, stderr=dev_null) 80 | stdout, stderr = script.communicate() 81 | if script.returncode == 0: 82 | # If `script' succeeded we assume that it understood our command line 83 | # invocation which means it's the Linux implementation (in this case 84 | # we can use standard output instead of a temporary file). 85 | output = stdout.decode(encoding) 86 | else: 87 | # If `script' failed we assume that it didn't understand our command 88 | # line invocation which means it's the MacOS (BSD) implementation 89 | # (in this case we need a temporary file because the command line 90 | # interface requires it). 91 | fd, temporary_file = tempfile.mkstemp(prefix='coloredlogs-', suffix='-capture.txt') 92 | try: 93 | command_line = ['script', '-q', temporary_file] + list(command) 94 | subprocess.Popen(command_line, stdout=dev_null, stderr=dev_null).wait() 95 | with codecs.open(temporary_file, 'r', encoding) as handle: 96 | output = handle.read() 97 | finally: 98 | os.unlink(temporary_file) 99 | # On MacOS when standard input is /dev/null I've observed 100 | # the captured output starting with the characters '^D': 101 | # 102 | # $ script -q capture.txt echo example ...`` element (a boolean, defaults 127 | to :data:`True`). 128 | :param tabsize: Refer to :func:`str.expandtabs()` for details. 129 | :returns: The text converted to HTML (a string). 130 | """ 131 | output = [] 132 | in_span = False 133 | compatible_text_styles = { 134 | # The following ANSI text styles have an obvious mapping to CSS. 135 | ANSI_TEXT_STYLES['bold']: {'font-weight': 'bold'}, 136 | ANSI_TEXT_STYLES['strike_through']: {'text-decoration': 'line-through'}, 137 | ANSI_TEXT_STYLES['underline']: {'text-decoration': 'underline'}, 138 | } 139 | for token in TOKEN_PATTERN.split(text): 140 | if token.startswith(('http://', 'https://', 'www.')): 141 | url = token if '://' in token else ('http://' + token) 142 | token = u'%s' % (html_encode(url), html_encode(token)) 143 | elif token.startswith(ANSI_CSI): 144 | ansi_codes = token[len(ANSI_CSI):-1].split(';') 145 | if all(c.isdigit() for c in ansi_codes): 146 | ansi_codes = list(map(int, ansi_codes)) 147 | # First we check for a reset code to close the previous 148 | # element. As explained on Wikipedia [1] an absence of codes 149 | # implies a reset code as well: "No parameters at all in ESC[m acts 150 | # like a 0 reset code". 151 | # [1] https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences 152 | if in_span and (0 in ansi_codes or not ansi_codes): 153 | output.append('') 154 | in_span = False 155 | # Now we're ready to generate the next element (if any) in 156 | # the knowledge that we're emitting opening and closing 157 | # tags in the correct order. 158 | styles = {} 159 | is_faint = (ANSI_TEXT_STYLES['faint'] in ansi_codes) 160 | is_inverse = (ANSI_TEXT_STYLES['inverse'] in ansi_codes) 161 | while ansi_codes: 162 | number = ansi_codes.pop(0) 163 | # Try to match a compatible text style. 164 | if number in compatible_text_styles: 165 | styles.update(compatible_text_styles[number]) 166 | continue 167 | # Try to extract a text and/or background color. 168 | text_color = None 169 | background_color = None 170 | if 30 <= number <= 37: 171 | # 30-37 sets the text color from the eight color palette. 172 | text_color = EIGHT_COLOR_PALETTE[number - 30] 173 | elif 40 <= number <= 47: 174 | # 40-47 sets the background color from the eight color palette. 175 | background_color = EIGHT_COLOR_PALETTE[number - 40] 176 | elif 90 <= number <= 97: 177 | # 90-97 sets the text color from the high-intensity eight color palette. 178 | text_color = BRIGHT_COLOR_PALETTE[number - 90] 179 | elif 100 <= number <= 107: 180 | # 100-107 sets the background color from the high-intensity eight color palette. 181 | background_color = BRIGHT_COLOR_PALETTE[number - 100] 182 | elif number in (38, 39) and len(ansi_codes) >= 2 and ansi_codes[0] == 5: 183 | # 38;5;N is a text color in the 256 color mode palette, 184 | # 39;5;N is a background color in the 256 color mode palette. 185 | try: 186 | # Consume the 5 following 38 or 39. 187 | ansi_codes.pop(0) 188 | # Consume the 256 color mode color index. 189 | color_index = ansi_codes.pop(0) 190 | # Set the variable to the corresponding HTML/CSS color. 191 | if number == 38: 192 | text_color = EXTENDED_COLOR_PALETTE[color_index] 193 | elif number == 39: 194 | background_color = EXTENDED_COLOR_PALETTE[color_index] 195 | except (ValueError, IndexError): 196 | pass 197 | # Apply the 'faint' or 'inverse' text style 198 | # by manipulating the selected color(s). 199 | if text_color and is_inverse: 200 | # Use the text color as the background color and pick a 201 | # text color that will be visible on the resulting 202 | # background color. 203 | background_color = text_color 204 | text_color = select_text_color(*parse_hex_color(text_color)) 205 | if text_color and is_faint: 206 | # Because I wasn't sure how to implement faint colors 207 | # based on normal colors I looked at how gnome-terminal 208 | # (my terminal of choice) handles this and it appears 209 | # to just pick a somewhat darker color. 210 | text_color = '#%02X%02X%02X' % tuple( 211 | max(0, n - 40) for n in parse_hex_color(text_color) 212 | ) 213 | if text_color: 214 | styles['color'] = text_color 215 | if background_color: 216 | styles['background-color'] = background_color 217 | if styles: 218 | token = '' % ';'.join(k + ':' + v for k, v in sorted(styles.items())) 219 | in_span = True 220 | else: 221 | token = '' 222 | else: 223 | token = html_encode(token) 224 | output.append(token) 225 | html = ''.join(output) 226 | html = encode_whitespace(html, tabsize) 227 | if code: 228 | html = '%s' % html 229 | return html 230 | 231 | 232 | def encode_whitespace(text, tabsize=4): 233 | """ 234 | Encode whitespace so that web browsers properly render it. 235 | 236 | :param text: The plain text (a string). 237 | :param tabsize: Refer to :func:`str.expandtabs()` for details. 238 | :returns: The text converted to HTML (a string). 239 | 240 | The purpose of this function is to encode whitespace in such a way that web 241 | browsers render the same whitespace regardless of whether 'preformatted' 242 | styling is used (by wrapping the text in a ``
...
`` element). 243 | 244 | .. note:: While the string manipulation performed by this function is 245 | specifically intended not to corrupt the HTML generated by 246 | :func:`convert()` it definitely does have the potential to 247 | corrupt HTML from other sources. You have been warned :-). 248 | """ 249 | # Convert Windows line endings (CR+LF) to UNIX line endings (LF). 250 | text = text.replace('\r\n', '\n') 251 | # Convert UNIX line endings (LF) to HTML line endings (
). 252 | text = text.replace('\n', '
\n') 253 | # Convert tabs to spaces. 254 | text = text.expandtabs(tabsize) 255 | # Convert leading spaces (that is to say spaces at the start of the string 256 | # and/or directly after a line ending) into non-breaking spaces, otherwise 257 | # HTML rendering engines will simply ignore these spaces. 258 | text = re.sub(INDENT_PATTERN, encode_whitespace_cb, text) 259 | # The conversion of leading spaces we just did misses a corner case where a 260 | # line starts with an HTML tag but the first visible text is a space. Web 261 | # browsers seem to ignore these spaces, so we need to convert them. 262 | text = re.sub(TAG_INDENT_PATTERN, r'\1 ', text) 263 | # Convert runs of multiple spaces into non-breaking spaces to avoid HTML 264 | # rendering engines from visually collapsing runs of spaces into a single 265 | # space. We specifically don't replace single spaces for several reasons: 266 | # 1. We'd break the HTML emitted by convert() by replacing spaces 267 | # inside HTML elements (for example the spaces that separate 268 | # element names from attribute names). 269 | # 2. If every single space is replaced by a non-breaking space, 270 | # web browsers perform awkwardly unintuitive word wrapping. 271 | # 3. The HTML output would be bloated for no good reason. 272 | text = re.sub(' {2,}', encode_whitespace_cb, text) 273 | return text 274 | 275 | 276 | def encode_whitespace_cb(match): 277 | """ 278 | Replace runs of multiple spaces with non-breaking spaces. 279 | 280 | :param match: A regular expression match object. 281 | :returns: The replacement string. 282 | 283 | This function is used by func:`encode_whitespace()` as a callback for 284 | replacement using a regular expression pattern. 285 | """ 286 | return ' ' * len(match.group(0)) 287 | 288 | 289 | def html_encode(text): 290 | """ 291 | Encode characters with a special meaning as HTML. 292 | 293 | :param text: The plain text (a string). 294 | :returns: The text converted to HTML (a string). 295 | """ 296 | text = text.replace('&', '&') 297 | text = text.replace('<', '<') 298 | text = text.replace('>', '>') 299 | text = text.replace('"', '"') 300 | return text 301 | 302 | 303 | def parse_hex_color(value): 304 | """ 305 | Convert a CSS color in hexadecimal notation into its R, G, B components. 306 | 307 | :param value: A CSS color in hexadecimal notation (a string like '#000000'). 308 | :return: A tuple with three integers (with values between 0 and 255) 309 | corresponding to the R, G and B components of the color. 310 | :raises: :exc:`~exceptions.ValueError` on values that can't be parsed. 311 | """ 312 | if value.startswith('#'): 313 | value = value[1:] 314 | if len(value) == 3: 315 | return ( 316 | int(value[0] * 2, 16), 317 | int(value[1] * 2, 16), 318 | int(value[2] * 2, 16), 319 | ) 320 | elif len(value) == 6: 321 | return ( 322 | int(value[0:2], 16), 323 | int(value[2:4], 16), 324 | int(value[4:6], 16), 325 | ) 326 | else: 327 | raise ValueError() 328 | 329 | 330 | def select_text_color(r, g, b): 331 | """ 332 | Choose a suitable color for the inverse text style. 333 | 334 | :param r: The amount of red (an integer between 0 and 255). 335 | :param g: The amount of green (an integer between 0 and 255). 336 | :param b: The amount of blue (an integer between 0 and 255). 337 | :returns: A CSS color in hexadecimal notation (a string). 338 | 339 | In inverse mode the color that is normally used for the text is instead 340 | used for the background, however this can render the text unreadable. The 341 | purpose of :func:`select_text_color()` is to make an effort to select a 342 | suitable text color. Based on http://stackoverflow.com/a/3943023/112731. 343 | """ 344 | return '#000' if (r * 0.299 + g * 0.587 + b * 0.114) > 186 else '#FFF' 345 | 346 | 347 | class ColoredCronMailer(object): 348 | 349 | """ 350 | Easy to use integration between :mod:`coloredlogs` and the UNIX ``cron`` daemon. 351 | 352 | By using :class:`ColoredCronMailer` as a context manager in the command 353 | line interface of your Python program you make it trivially easy for users 354 | of your program to opt in to HTML output under ``cron``: The only thing the 355 | user needs to do is set ``CONTENT_TYPE="text/html"`` in their crontab! 356 | 357 | Under the hood this requires quite a bit of magic and I must admit that I 358 | developed this code simply because I was curious whether it could even be 359 | done :-). It requires my :mod:`capturer` package which you can install 360 | using ``pip install 'coloredlogs[cron]'``. The ``[cron]`` extra will pull 361 | in the :mod:`capturer` 2.4 or newer which is required to capture the output 362 | while silencing it - otherwise you'd get duplicate output in the emails 363 | sent by ``cron``. 364 | """ 365 | 366 | def __init__(self): 367 | """Initialize output capturing when running under ``cron`` with the correct configuration.""" 368 | self.is_enabled = 'text/html' in os.environ.get('CONTENT_TYPE', 'text/plain') 369 | self.is_silent = False 370 | if self.is_enabled: 371 | # We import capturer here so that the coloredlogs[cron] extra 372 | # isn't required to use the other functions in this module. 373 | from capturer import CaptureOutput 374 | self.capturer = CaptureOutput(merged=True, relay=False) 375 | 376 | def __enter__(self): 377 | """Start capturing output (when applicable).""" 378 | if self.is_enabled: 379 | self.capturer.__enter__() 380 | return self 381 | 382 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 383 | """Stop capturing output and convert the output to HTML (when applicable).""" 384 | if self.is_enabled: 385 | if not self.is_silent: 386 | # Only call output() when we captured something useful. 387 | text = self.capturer.get_text() 388 | if text and not text.isspace(): 389 | output(convert(text)) 390 | self.capturer.__exit__(exc_type, exc_value, traceback) 391 | 392 | def silence(self): 393 | """ 394 | Tell :func:`__exit__()` to swallow all output (things will be silent). 395 | 396 | This can be useful when a Python program is written in such a way that 397 | it has already produced output by the time it becomes apparent that 398 | nothing useful can be done (say in a cron job that runs every few 399 | minutes :-p). By calling :func:`silence()` the output can be swallowed 400 | retroactively, avoiding useless emails from ``cron``. 401 | """ 402 | self.is_silent = True 403 | -------------------------------------------------------------------------------- /coloredlogs/converter/colors.py: -------------------------------------------------------------------------------- 1 | # Mapping of ANSI color codes to HTML/CSS colors. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: January 14, 2018 5 | # URL: https://coloredlogs.readthedocs.io 6 | 7 | """Mapping of ANSI color codes to HTML/CSS colors.""" 8 | 9 | EIGHT_COLOR_PALETTE = ( 10 | '#010101', # black 11 | '#DE382B', # red 12 | '#39B54A', # green 13 | '#FFC706', # yellow 14 | '#006FB8', # blue 15 | '#762671', # magenta 16 | '#2CB5E9', # cyan 17 | '#CCC', # white 18 | ) 19 | """ 20 | A tuple of strings mapping basic color codes to CSS colors. 21 | 22 | The items in this tuple correspond to the eight basic color codes for black, 23 | red, green, yellow, blue, magenta, cyan and white as defined in the original 24 | standard for ANSI escape sequences. The CSS colors are based on the `Ubuntu 25 | color scheme`_ described on Wikipedia and they are encoded as hexadecimal 26 | values to get the shortest strings, which reduces the size (in bytes) of 27 | conversion output. 28 | 29 | .. _Ubuntu color scheme: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors 30 | """ 31 | 32 | BRIGHT_COLOR_PALETTE = ( 33 | '#808080', # black 34 | '#F00', # red 35 | '#0F0', # green 36 | '#FF0', # yellow 37 | '#00F', # blue 38 | '#F0F', # magenta 39 | '#0FF', # cyan 40 | '#FFF', # white 41 | ) 42 | """ 43 | A tuple of strings mapping bright color codes to CSS colors. 44 | 45 | This tuple maps the bright color variants of :data:`EIGHT_COLOR_PALETTE`. 46 | """ 47 | 48 | EXTENDED_COLOR_PALETTE = ( 49 | '#000000', 50 | '#800000', 51 | '#008000', 52 | '#808000', 53 | '#000080', 54 | '#800080', 55 | '#008080', 56 | '#C0C0C0', 57 | '#808080', 58 | '#FF0000', 59 | '#00FF00', 60 | '#FFFF00', 61 | '#0000FF', 62 | '#FF00FF', 63 | '#00FFFF', 64 | '#FFFFFF', 65 | '#000000', 66 | '#00005F', 67 | '#000087', 68 | '#0000AF', 69 | '#0000D7', 70 | '#0000FF', 71 | '#005F00', 72 | '#005F5F', 73 | '#005F87', 74 | '#005FAF', 75 | '#005FD7', 76 | '#005FFF', 77 | '#008700', 78 | '#00875F', 79 | '#008787', 80 | '#0087AF', 81 | '#0087D7', 82 | '#0087FF', 83 | '#00AF00', 84 | '#00AF5F', 85 | '#00AF87', 86 | '#00AFAF', 87 | '#00AFD7', 88 | '#00AFFF', 89 | '#00D700', 90 | '#00D75F', 91 | '#00D787', 92 | '#00D7AF', 93 | '#00D7D7', 94 | '#00D7FF', 95 | '#00FF00', 96 | '#00FF5F', 97 | '#00FF87', 98 | '#00FFAF', 99 | '#00FFD7', 100 | '#00FFFF', 101 | '#5F0000', 102 | '#5F005F', 103 | '#5F0087', 104 | '#5F00AF', 105 | '#5F00D7', 106 | '#5F00FF', 107 | '#5F5F00', 108 | '#5F5F5F', 109 | '#5F5F87', 110 | '#5F5FAF', 111 | '#5F5FD7', 112 | '#5F5FFF', 113 | '#5F8700', 114 | '#5F875F', 115 | '#5F8787', 116 | '#5F87AF', 117 | '#5F87D7', 118 | '#5F87FF', 119 | '#5FAF00', 120 | '#5FAF5F', 121 | '#5FAF87', 122 | '#5FAFAF', 123 | '#5FAFD7', 124 | '#5FAFFF', 125 | '#5FD700', 126 | '#5FD75F', 127 | '#5FD787', 128 | '#5FD7AF', 129 | '#5FD7D7', 130 | '#5FD7FF', 131 | '#5FFF00', 132 | '#5FFF5F', 133 | '#5FFF87', 134 | '#5FFFAF', 135 | '#5FFFD7', 136 | '#5FFFFF', 137 | '#870000', 138 | '#87005F', 139 | '#870087', 140 | '#8700AF', 141 | '#8700D7', 142 | '#8700FF', 143 | '#875F00', 144 | '#875F5F', 145 | '#875F87', 146 | '#875FAF', 147 | '#875FD7', 148 | '#875FFF', 149 | '#878700', 150 | '#87875F', 151 | '#878787', 152 | '#8787AF', 153 | '#8787D7', 154 | '#8787FF', 155 | '#87AF00', 156 | '#87AF5F', 157 | '#87AF87', 158 | '#87AFAF', 159 | '#87AFD7', 160 | '#87AFFF', 161 | '#87D700', 162 | '#87D75F', 163 | '#87D787', 164 | '#87D7AF', 165 | '#87D7D7', 166 | '#87D7FF', 167 | '#87FF00', 168 | '#87FF5F', 169 | '#87FF87', 170 | '#87FFAF', 171 | '#87FFD7', 172 | '#87FFFF', 173 | '#AF0000', 174 | '#AF005F', 175 | '#AF0087', 176 | '#AF00AF', 177 | '#AF00D7', 178 | '#AF00FF', 179 | '#AF5F00', 180 | '#AF5F5F', 181 | '#AF5F87', 182 | '#AF5FAF', 183 | '#AF5FD7', 184 | '#AF5FFF', 185 | '#AF8700', 186 | '#AF875F', 187 | '#AF8787', 188 | '#AF87AF', 189 | '#AF87D7', 190 | '#AF87FF', 191 | '#AFAF00', 192 | '#AFAF5F', 193 | '#AFAF87', 194 | '#AFAFAF', 195 | '#AFAFD7', 196 | '#AFAFFF', 197 | '#AFD700', 198 | '#AFD75F', 199 | '#AFD787', 200 | '#AFD7AF', 201 | '#AFD7D7', 202 | '#AFD7FF', 203 | '#AFFF00', 204 | '#AFFF5F', 205 | '#AFFF87', 206 | '#AFFFAF', 207 | '#AFFFD7', 208 | '#AFFFFF', 209 | '#D70000', 210 | '#D7005F', 211 | '#D70087', 212 | '#D700AF', 213 | '#D700D7', 214 | '#D700FF', 215 | '#D75F00', 216 | '#D75F5F', 217 | '#D75F87', 218 | '#D75FAF', 219 | '#D75FD7', 220 | '#D75FFF', 221 | '#D78700', 222 | '#D7875F', 223 | '#D78787', 224 | '#D787AF', 225 | '#D787D7', 226 | '#D787FF', 227 | '#D7AF00', 228 | '#D7AF5F', 229 | '#D7AF87', 230 | '#D7AFAF', 231 | '#D7AFD7', 232 | '#D7AFFF', 233 | '#D7D700', 234 | '#D7D75F', 235 | '#D7D787', 236 | '#D7D7AF', 237 | '#D7D7D7', 238 | '#D7D7FF', 239 | '#D7FF00', 240 | '#D7FF5F', 241 | '#D7FF87', 242 | '#D7FFAF', 243 | '#D7FFD7', 244 | '#D7FFFF', 245 | '#FF0000', 246 | '#FF005F', 247 | '#FF0087', 248 | '#FF00AF', 249 | '#FF00D7', 250 | '#FF00FF', 251 | '#FF5F00', 252 | '#FF5F5F', 253 | '#FF5F87', 254 | '#FF5FAF', 255 | '#FF5FD7', 256 | '#FF5FFF', 257 | '#FF8700', 258 | '#FF875F', 259 | '#FF8787', 260 | '#FF87AF', 261 | '#FF87D7', 262 | '#FF87FF', 263 | '#FFAF00', 264 | '#FFAF5F', 265 | '#FFAF87', 266 | '#FFAFAF', 267 | '#FFAFD7', 268 | '#FFAFFF', 269 | '#FFD700', 270 | '#FFD75F', 271 | '#FFD787', 272 | '#FFD7AF', 273 | '#FFD7D7', 274 | '#FFD7FF', 275 | '#FFFF00', 276 | '#FFFF5F', 277 | '#FFFF87', 278 | '#FFFFAF', 279 | '#FFFFD7', 280 | '#FFFFFF', 281 | '#080808', 282 | '#121212', 283 | '#1C1C1C', 284 | '#262626', 285 | '#303030', 286 | '#3A3A3A', 287 | '#444444', 288 | '#4E4E4E', 289 | '#585858', 290 | '#626262', 291 | '#6C6C6C', 292 | '#767676', 293 | '#808080', 294 | '#8A8A8A', 295 | '#949494', 296 | '#9E9E9E', 297 | '#A8A8A8', 298 | '#B2B2B2', 299 | '#BCBCBC', 300 | '#C6C6C6', 301 | '#D0D0D0', 302 | '#DADADA', 303 | '#E4E4E4', 304 | '#EEEEEE', 305 | ) 306 | """ 307 | A tuple of strings mapping 256 color mode color codes to CSS colors. 308 | 309 | The items in this tuple correspond to the color codes in the 256 color mode palette. 310 | """ 311 | -------------------------------------------------------------------------------- /coloredlogs/demo.py: -------------------------------------------------------------------------------- 1 | # Demonstration of the coloredlogs package. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: January 14, 2018 5 | # URL: https://coloredlogs.readthedocs.io 6 | 7 | """A simple demonstration of the `coloredlogs` package.""" 8 | 9 | # Standard library modules. 10 | import os 11 | import time 12 | 13 | # Modules included in our package. 14 | import coloredlogs 15 | 16 | # If my verbose logger is installed, we'll use that for the demo. 17 | try: 18 | from verboselogs import VerboseLogger as getLogger 19 | except ImportError: 20 | from logging import getLogger 21 | 22 | # Initialize a logger for this module. 23 | logger = getLogger(__name__) 24 | 25 | DEMO_DELAY = float(os.environ.get('COLOREDLOGS_DEMO_DELAY', '1')) 26 | """The number of seconds between each message emitted by :func:`demonstrate_colored_logging()`.""" 27 | 28 | 29 | def demonstrate_colored_logging(): 30 | """Interactively demonstrate the :mod:`coloredlogs` package.""" 31 | # Determine the available logging levels and order them by numeric value. 32 | decorated_levels = [] 33 | defined_levels = coloredlogs.find_defined_levels() 34 | normalizer = coloredlogs.NameNormalizer() 35 | for name, level in defined_levels.items(): 36 | if name != 'NOTSET': 37 | item = (level, normalizer.normalize_name(name)) 38 | if item not in decorated_levels: 39 | decorated_levels.append(item) 40 | ordered_levels = sorted(decorated_levels) 41 | # Initialize colored output to the terminal, default to the most 42 | # verbose logging level but enable the user the customize it. 43 | coloredlogs.install(level=os.environ.get('COLOREDLOGS_LOG_LEVEL', ordered_levels[0][1])) 44 | # Print some examples with different timestamps. 45 | for level, name in ordered_levels: 46 | log_method = getattr(logger, name, None) 47 | if log_method: 48 | log_method("message with level %s (%i)", name, level) 49 | time.sleep(DEMO_DELAY) 50 | -------------------------------------------------------------------------------- /coloredlogs/syslog.py: -------------------------------------------------------------------------------- 1 | # Easy to use system logging for Python's logging module. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: April 17, 2017 5 | # URL: https://coloredlogs.readthedocs.io 6 | 7 | """ 8 | Easy to use UNIX system logging for Python's :mod:`logging` module. 9 | 10 | Admittedly system logging has little to do with colored terminal output, however: 11 | 12 | - The `coloredlogs` package is my attempt to do Python logging right and system 13 | logging is an important part of that equation. 14 | 15 | - I've seen a surprising number of quirks and mistakes in system logging done 16 | in Python, for example including ``%(asctime)s`` in a format string (the 17 | system logging daemon is responsible for adding timestamps and thus you end 18 | up with duplicate timestamps that make the logs awful to read :-). 19 | 20 | - The ``%(programname)s`` filter originated in my system logging code and I 21 | wanted it in `coloredlogs` so the step to include this module wasn't that big. 22 | 23 | - As a bonus this Python module now has a test suite and proper documentation. 24 | 25 | So there :-P. Go take a look at :func:`enable_system_logging()`. 26 | """ 27 | 28 | # Standard library modules. 29 | import logging 30 | import logging.handlers 31 | import os 32 | import socket 33 | import sys 34 | 35 | # Modules included in our package. 36 | from coloredlogs import ( 37 | DEFAULT_LOG_LEVEL, 38 | ProgramNameFilter, 39 | adjust_level, 40 | find_program_name, 41 | level_to_number, 42 | replace_handler, 43 | ) 44 | 45 | LOG_DEVICE_MACOSX = '/var/run/syslog' 46 | """The pathname of the log device on Mac OS X (a string).""" 47 | 48 | LOG_DEVICE_UNIX = '/dev/log' 49 | """The pathname of the log device on Linux and most other UNIX systems (a string).""" 50 | 51 | DEFAULT_LOG_FORMAT = '%(programname)s[%(process)d]: %(levelname)s %(message)s' 52 | """ 53 | The default format for log messages sent to the system log (a string). 54 | 55 | The ``%(programname)s`` format requires :class:`~coloredlogs.ProgramNameFilter` 56 | but :func:`enable_system_logging()` takes care of this for you. 57 | 58 | The ``name[pid]:`` construct (specifically the colon) in the format allows 59 | rsyslogd_ to extract the ``$programname`` from each log message, which in turn 60 | allows configuration files in ``/etc/rsyslog.d/*.conf`` to filter these log 61 | messages to a separate log file (if the need arises). 62 | 63 | .. _rsyslogd: https://en.wikipedia.org/wiki/Rsyslog 64 | """ 65 | 66 | # Initialize a logger for this module. 67 | logger = logging.getLogger(__name__) 68 | 69 | 70 | class SystemLogging(object): 71 | 72 | """Context manager to enable system logging.""" 73 | 74 | def __init__(self, *args, **kw): 75 | """ 76 | Initialize a :class:`SystemLogging` object. 77 | 78 | :param args: Positional arguments to :func:`enable_system_logging()`. 79 | :param kw: Keyword arguments to :func:`enable_system_logging()`. 80 | """ 81 | self.args = args 82 | self.kw = kw 83 | self.handler = None 84 | 85 | def __enter__(self): 86 | """Enable system logging when entering the context.""" 87 | if self.handler is None: 88 | self.handler = enable_system_logging(*self.args, **self.kw) 89 | return self.handler 90 | 91 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 92 | """ 93 | Disable system logging when leaving the context. 94 | 95 | .. note:: If an exception is being handled when we leave the context a 96 | warning message including traceback is logged *before* system 97 | logging is disabled. 98 | """ 99 | if self.handler is not None: 100 | if exc_type is not None: 101 | logger.warning("Disabling system logging due to unhandled exception!", exc_info=True) 102 | (self.kw.get('logger') or logging.getLogger()).removeHandler(self.handler) 103 | self.handler = None 104 | 105 | 106 | def enable_system_logging(programname=None, fmt=None, logger=None, reconfigure=True, **kw): 107 | """ 108 | Redirect :mod:`logging` messages to the system log (e.g. ``/var/log/syslog``). 109 | 110 | :param programname: The program name to embed in log messages (a string, defaults 111 | to the result of :func:`~coloredlogs.find_program_name()`). 112 | :param fmt: The log format for system log messages (a string, defaults to 113 | :data:`DEFAULT_LOG_FORMAT`). 114 | :param logger: The logger to which the :class:`~logging.handlers.SysLogHandler` 115 | should be connected (defaults to the root logger). 116 | :param level: The logging level for the :class:`~logging.handlers.SysLogHandler` 117 | (defaults to :data:`.DEFAULT_LOG_LEVEL`). This value is coerced 118 | using :func:`~coloredlogs.level_to_number()`. 119 | :param reconfigure: If :data:`True` (the default) multiple calls to 120 | :func:`enable_system_logging()` will each override 121 | the previous configuration. 122 | :param kw: Refer to :func:`connect_to_syslog()`. 123 | :returns: A :class:`~logging.handlers.SysLogHandler` object or 124 | :data:`None`. If an existing handler is found and `reconfigure` 125 | is :data:`False` the existing handler object is returned. If the 126 | connection to the system logging daemon fails :data:`None` is 127 | returned. 128 | 129 | .. note:: When the logger's effective level is too restrictive it is 130 | relaxed (refer to `notes about log levels`_ for details). 131 | """ 132 | # Provide defaults for omitted arguments. 133 | programname = programname or find_program_name() 134 | logger = logger or logging.getLogger() 135 | fmt = fmt or DEFAULT_LOG_FORMAT 136 | level = level_to_number(kw.get('level', DEFAULT_LOG_LEVEL)) 137 | # Check whether system logging is already enabled. 138 | handler, logger = replace_handler(logger, match_syslog_handler, reconfigure) 139 | # Make sure reconfiguration is allowed or not relevant. 140 | if not (handler and not reconfigure): 141 | # Create a system logging handler. 142 | handler = connect_to_syslog(**kw) 143 | # Make sure the handler was successfully created. 144 | if handler: 145 | # Enable the use of %(programname)s. 146 | ProgramNameFilter.install(handler=handler, fmt=fmt, programname=programname) 147 | # Connect the formatter, handler and logger. 148 | handler.setFormatter(logging.Formatter(fmt)) 149 | logger.addHandler(handler) 150 | # Adjust the level of the selected logger. 151 | adjust_level(logger, level) 152 | return handler 153 | 154 | 155 | def connect_to_syslog(address=None, facility=None, level=None): 156 | """ 157 | Create a :class:`~logging.handlers.SysLogHandler`. 158 | 159 | :param address: The device file or network address of the system logging 160 | daemon (a string or tuple, defaults to the result of 161 | :func:`find_syslog_address()`). 162 | :param facility: Refer to :class:`~logging.handlers.SysLogHandler`. 163 | Defaults to ``LOG_USER``. 164 | :param level: The logging level for the :class:`~logging.handlers.SysLogHandler` 165 | (defaults to :data:`.DEFAULT_LOG_LEVEL`). This value is coerced 166 | using :func:`~coloredlogs.level_to_number()`. 167 | :returns: A :class:`~logging.handlers.SysLogHandler` object or :data:`None` (if the 168 | system logging daemon is unavailable). 169 | 170 | The process of connecting to the system logging daemon goes as follows: 171 | 172 | - If :class:`~logging.handlers.SysLogHandler` supports the `socktype` 173 | option (it does since Python 2.7) the following two socket types are 174 | tried (in decreasing preference): 175 | 176 | 1. :data:`~socket.SOCK_RAW` avoids truncation of log messages but may 177 | not be supported. 178 | 2. :data:`~socket.SOCK_STREAM` (TCP) supports longer messages than the 179 | default (which is UDP). 180 | 181 | - If socket types are not supported Python's (2.6) defaults are used to 182 | connect to the selected `address`. 183 | """ 184 | if not address: 185 | address = find_syslog_address() 186 | if facility is None: 187 | facility = logging.handlers.SysLogHandler.LOG_USER 188 | if level is None: 189 | level = DEFAULT_LOG_LEVEL 190 | for socktype in socket.SOCK_RAW, socket.SOCK_STREAM, None: 191 | kw = dict(facility=facility, address=address) 192 | if socktype is not None: 193 | kw['socktype'] = socktype 194 | try: 195 | handler = logging.handlers.SysLogHandler(**kw) 196 | except (IOError, TypeError): 197 | # The socktype argument was added in Python 2.7 and its use will raise a 198 | # TypeError exception on Python 2.6. IOError is a superclass of socket.error 199 | # (since Python 2.6) which can be raised if the system logging daemon is 200 | # unavailable. 201 | pass 202 | else: 203 | handler.setLevel(level_to_number(level)) 204 | return handler 205 | 206 | 207 | def find_syslog_address(): 208 | """ 209 | Find the most suitable destination for system log messages. 210 | 211 | :returns: The pathname of a log device (a string) or an address/port tuple as 212 | supported by :class:`~logging.handlers.SysLogHandler`. 213 | 214 | On Mac OS X this prefers :data:`LOG_DEVICE_MACOSX`, after that :data:`LOG_DEVICE_UNIX` 215 | is checked for existence. If both of these device files don't exist the default used 216 | by :class:`~logging.handlers.SysLogHandler` is returned. 217 | """ 218 | if sys.platform == 'darwin' and os.path.exists(LOG_DEVICE_MACOSX): 219 | return LOG_DEVICE_MACOSX 220 | elif os.path.exists(LOG_DEVICE_UNIX): 221 | return LOG_DEVICE_UNIX 222 | else: 223 | return 'localhost', logging.handlers.SYSLOG_UDP_PORT 224 | 225 | 226 | def match_syslog_handler(handler): 227 | """ 228 | Identify system logging handlers. 229 | 230 | :param handler: The :class:`~logging.Handler` class to check. 231 | :returns: :data:`True` if the handler is a 232 | :class:`~logging.handlers.SysLogHandler`, 233 | :data:`False` otherwise. 234 | 235 | This function can be used as a callback for :func:`.find_handler()`. 236 | """ 237 | return isinstance(handler, logging.handlers.SysLogHandler) 238 | -------------------------------------------------------------------------------- /coloredlogs/tests.py: -------------------------------------------------------------------------------- 1 | # Automated tests for the `coloredlogs' package. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: April 29, 2018 5 | # URL: https://coloredlogs.readthedocs.io 6 | 7 | """Automated tests for the `coloredlogs` package.""" 8 | 9 | # Standard library modules. 10 | import contextlib 11 | import imp 12 | import logging 13 | import logging.handlers 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | import tempfile 19 | 20 | # External dependencies. 21 | from humanfriendly.compat import StringIO 22 | from humanfriendly.terminal import ANSI_COLOR_CODES, ansi_style, ansi_wrap 23 | from humanfriendly.testing import PatchedItem, TestCase, retry 24 | from humanfriendly.text import format, random_string 25 | from mock import MagicMock 26 | 27 | # The module we're testing. 28 | import coloredlogs 29 | import coloredlogs.cli 30 | from coloredlogs import ( 31 | CHROOT_FILES, 32 | ColoredFormatter, 33 | NameNormalizer, 34 | decrease_verbosity, 35 | find_defined_levels, 36 | find_handler, 37 | find_hostname, 38 | find_program_name, 39 | get_level, 40 | increase_verbosity, 41 | install, 42 | is_verbose, 43 | level_to_number, 44 | match_stream_handler, 45 | parse_encoded_styles, 46 | set_level, 47 | walk_propagation_tree, 48 | ) 49 | from coloredlogs.syslog import SystemLogging, match_syslog_handler 50 | from coloredlogs.converter import ( 51 | ColoredCronMailer, 52 | EIGHT_COLOR_PALETTE, 53 | capture, 54 | convert, 55 | ) 56 | 57 | # External test dependencies. 58 | from capturer import CaptureOutput 59 | from verboselogs import VerboseLogger 60 | 61 | # Compiled regular expression that matches a single line of output produced by 62 | # the default log format (does not include matching of ANSI escape sequences). 63 | PLAIN_TEXT_PATTERN = re.compile(r''' 64 | (?P \d{4}-\d{2}-\d{2} ) 65 | \s (?P