├── .circleci └── config.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── certbuilder ├── __init__.py └── version.py ├── changelog.md ├── dev ├── __init__.py ├── _import.py ├── _pep425.py ├── _task.py ├── api_docs.py ├── build.py ├── ci-cleanup.py ├── ci-driver.py ├── ci.py ├── codecov.json ├── coverage.py ├── deps.py ├── lint.py ├── pyenv-install.py ├── python-install.py ├── release.py ├── tests.py └── version.py ├── docs ├── api.md └── readme.md ├── readme.md ├── requires ├── api_docs ├── ci ├── coverage ├── lint └── release ├── run.py ├── setup.py ├── tests ├── __init__.py └── test_certificate_builder.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | python2.7: 4 | machine: 5 | image: ubuntu-2004:202101-01 6 | resource_class: arm.medium 7 | steps: 8 | - checkout 9 | - run: python run.py deps 10 | - run: python run.py ci-driver 11 | python3.9: 12 | machine: 13 | image: ubuntu-2004:202101-01 14 | resource_class: arm.medium 15 | steps: 16 | - checkout 17 | - run: python run.py deps 18 | - run: python3 run.py ci-driver 19 | workflows: 20 | version: 2 21 | arm64: 22 | jobs: 23 | - python2.7 24 | - python3.9 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build-windows: 6 | name: Python ${{ matrix.python }} on windows-2019 ${{ matrix.arch }} 7 | runs-on: windows-2019 8 | strategy: 9 | matrix: 10 | python: 11 | - '3.11' 12 | # - 'pypy-3.7-v7.3.5' 13 | arch: 14 | - 'x86' 15 | - 'x64' 16 | exclude: 17 | - python: 'pypy-3.7-v7.3.5' 18 | arch: x86 19 | steps: 20 | - uses: actions/checkout@master 21 | - uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python }} 24 | architecture: ${{ matrix.arch }} 25 | - name: Install dependencies 26 | run: python run.py deps 27 | - name: Run test suite 28 | run: python run.py ci-driver 29 | - name: Run test suite (Windows legacy API) 30 | run: python run.py ci-driver winlegacy 31 | 32 | build-windows-old: 33 | name: Python ${{ matrix.python }} on windows-2019 ${{ matrix.arch }} 34 | runs-on: windows-2019 35 | strategy: 36 | matrix: 37 | python: 38 | - '2.6' 39 | - '2.7' 40 | - '3.3' 41 | arch: 42 | - 'x86' 43 | - 'x64' 44 | steps: 45 | - uses: actions/checkout@master 46 | 47 | - name: Cache Python 48 | id: cache-python 49 | uses: actions/cache@v2 50 | with: 51 | path: ~/AppData/Local/Python${{ matrix.python }}-${{ matrix.arch }} 52 | key: windows-2019-python-${{ matrix.python }}-${{ matrix.arch }} 53 | 54 | - name: Install Python ${{ matrix.python }} 55 | run: python run.py python-install ${{ matrix.python }} ${{ matrix.arch }} | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 56 | 57 | - name: Install dependencies 58 | run: python run.py deps 59 | - name: Run test suite 60 | run: python run.py ci-driver 61 | - name: Run test suite (Windows legacy API) 62 | run: python run.py ci-driver winlegacy 63 | 64 | build-mac: 65 | name: Python ${{ matrix.python }} on macos-13 66 | runs-on: macos-13 67 | strategy: 68 | matrix: 69 | python: 70 | - '3.11' 71 | steps: 72 | - uses: actions/checkout@master 73 | - uses: actions/setup-python@v2 74 | with: 75 | python-version: ${{ matrix.python }} 76 | architecture: x64 77 | - name: Install dependencies 78 | run: python run.py deps 79 | - name: Run test suite 80 | run: python run.py ci-driver 81 | - name: Run test suite (Mac cffi) 82 | run: python run.py ci-driver cffi 83 | - name: Run test suite (Mac OpenSSL) 84 | run: python run.py ci-driver openssl 85 | - name: Run test suite (Mac OpenSSL/cffi) 86 | run: python run.py ci-driver cffi openssl 87 | 88 | build-mac-legacy: 89 | name: Python ${{ matrix.python }} on macos-11 90 | runs-on: macos-11 91 | strategy: 92 | matrix: 93 | python: 94 | - '3.7' 95 | - '3.11' 96 | steps: 97 | - uses: actions/checkout@master 98 | - uses: actions/setup-python@v2 99 | with: 100 | python-version: ${{ matrix.python }} 101 | architecture: x64 102 | - name: Install dependencies 103 | run: python run.py deps 104 | - name: Run test suite 105 | run: python run.py ci-driver 106 | - name: Run test suite (Mac cffi) 107 | run: python run.py ci-driver cffi 108 | - name: Run test suite (Mac OpenSSL) 109 | run: python run.py ci-driver openssl 110 | - name: Run test suite (Mac OpenSSL/cffi) 111 | run: python run.py ci-driver cffi openssl 112 | 113 | build-mac-old: 114 | name: Python ${{ matrix.python }} on macos-11 115 | runs-on: macos-11 116 | strategy: 117 | matrix: 118 | python: 119 | - '2.6' 120 | - '2.7' 121 | - '3.3' 122 | env: 123 | PYTHONIOENCODING: 'utf-8:surrogateescape' 124 | steps: 125 | - uses: actions/checkout@master 126 | 127 | - name: Check pyenv 128 | id: check-pyenv 129 | uses: actions/cache@v2 130 | with: 131 | path: ~/.pyenv 132 | key: macos-11-${{ matrix.python }}-pyenv 133 | 134 | - name: Install Python ${{ matrix.python }} 135 | run: python run.py pyenv-install ${{ matrix.python }} >> $GITHUB_PATH 136 | 137 | - name: Install dependencies 138 | run: python run.py deps 139 | - name: Run test suite 140 | run: python run.py ci-driver 141 | - name: Run test suite (Mac cffi) 142 | run: python run.py ci-driver cffi 143 | - name: Run test suite (Mac OpenSSL) 144 | run: python run.py ci-driver openssl 145 | - name: Run test suite (Mac OpenSSL/cffi) 146 | run: python run.py ci-driver cffi openssl 147 | 148 | build-mac-openssl3: 149 | name: Python ${{ matrix.python }} on macos-11 with OpenSSL 3.0 150 | runs-on: macos-11 151 | strategy: 152 | matrix: 153 | python: 154 | - '3.6' 155 | - '3.11' 156 | steps: 157 | - uses: actions/checkout@master 158 | - uses: actions/setup-python@v2 159 | with: 160 | python-version: ${{ matrix.python }} 161 | architecture: x64 162 | - name: Install OpenSSL 3.0 163 | run: brew install openssl@3 164 | - name: Install dependencies 165 | run: python run.py deps 166 | - name: Run test suite (Mac OpenSSL 3.0) 167 | run: python run.py ci-driver openssl3 168 | - name: Run test suite (Mac OpenSSL 3.0/cffi) 169 | run: python run.py ci-driver cffi openssl3 170 | 171 | build-ubuntu: 172 | name: Python ${{ matrix.python }} on ubuntu-20.04 x64 173 | runs-on: ubuntu-20.04 174 | strategy: 175 | matrix: 176 | python: 177 | - '3.9' 178 | - '3.10' 179 | - '3.11' 180 | - 'pypy-3.7-v7.3.5' 181 | steps: 182 | - uses: actions/checkout@master 183 | - uses: actions/setup-python@v2 184 | with: 185 | python-version: ${{ matrix.python }} 186 | architecture: x64 187 | - name: Install dependencies 188 | run: python run.py deps 189 | - name: Run test suite 190 | run: python run.py ci-driver 191 | 192 | build-ubuntu-openssl3-py3: 193 | name: Python 3 on (Docker) ubuntu-22.04 x64 194 | runs-on: ubuntu-latest 195 | container: ubuntu:22.04 196 | steps: 197 | - uses: actions/checkout@master 198 | - name: Install Python and OpenSSL 199 | run: DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y --no-install-recommends python3 python3-setuptools python-is-python3 openssl curl ca-certificates git 200 | - name: Install dependencies 201 | run: python run.py deps 202 | - name: Run test suite 203 | run: python run.py ci-driver 204 | - name: Run test suite (cffi) 205 | run: python run.py ci-driver cffi 206 | 207 | build-ubuntu-openssl3-py2: 208 | name: Python 2 on (Docker) ubuntu-22.04 x64 209 | runs-on: ubuntu-latest 210 | container: ubuntu:22.04 211 | steps: 212 | - uses: actions/checkout@master 213 | - name: Install Python and OpenSSL 214 | run: DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y --no-install-recommends python2 python-setuptools openssl curl ca-certificates git 215 | - name: Install dependencies 216 | run: python2 run.py deps 217 | - name: Run test suite 218 | run: python2 run.py ci-driver 219 | - name: Run test suite (cffi) 220 | run: python2 run.py ci-driver cffi 221 | 222 | 223 | build-ubuntu-old: 224 | name: Python ${{ matrix.python }} on ubuntu-20.04 x64 225 | runs-on: ubuntu-20.04 226 | strategy: 227 | matrix: 228 | python: 229 | - '3.6' 230 | - '3.7' 231 | steps: 232 | - uses: actions/checkout@master 233 | - name: Setup deadsnakes/ppa 234 | run: sudo apt-add-repository ppa:deadsnakes/ppa 235 | - name: Update apt 236 | run: sudo apt-get update 237 | - name: Install Python ${{matrix.python}} 238 | run: sudo apt-get install python${{matrix.python}} python${{matrix.python}}-distutils 239 | - name: Install dependencies 240 | run: python${{matrix.python}} run.py deps 241 | - name: Run test suite 242 | run: python${{matrix.python}} run.py ci-driver 243 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | .eggs/ 3 | .tox/ 4 | __pycache__/ 5 | build/ 6 | dist/ 7 | tests/output/ 8 | *.pyc 9 | .coverage 10 | .DS_Store 11 | .python-version 12 | coverage.xml 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2019 Will Bond 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /certbuilder/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | from datetime import datetime, timedelta 5 | import inspect 6 | import re 7 | import sys 8 | import textwrap 9 | import time 10 | 11 | from asn1crypto import x509, keys, core 12 | from asn1crypto.util import int_to_bytes, int_from_bytes, timezone 13 | from oscrypto import asymmetric, util 14 | 15 | from .version import __version__, __version_info__ 16 | 17 | if sys.version_info < (3,): 18 | int_types = (int, long) # noqa 19 | str_cls = unicode # noqa 20 | byte_cls = str 21 | else: 22 | int_types = (int,) 23 | str_cls = str 24 | byte_cls = bytes 25 | 26 | 27 | __all__ = [ 28 | '__version__', 29 | '__version_info__', 30 | 'CertificateBuilder', 31 | 'pem_armor_certificate', 32 | ] 33 | 34 | 35 | def _writer(func): 36 | """ 37 | Decorator for a custom writer, but a default reader 38 | """ 39 | 40 | name = func.__name__ 41 | return property(fget=lambda self: getattr(self, '_%s' % name), fset=func) 42 | 43 | 44 | def pem_armor_certificate(certificate): 45 | """ 46 | Encodes a certificate into PEM format 47 | 48 | :param certificate: 49 | An asn1crypto.x509.Certificate object of the certificate to armor. 50 | Typically this is obtained from CertificateBuilder.build(). 51 | 52 | :return: 53 | A byte string of the PEM-encoded certificate 54 | """ 55 | 56 | return asymmetric.dump_certificate(certificate) 57 | 58 | 59 | class CertificateBuilder(object): 60 | 61 | _self_signed = False 62 | _serial_number = None 63 | _issuer = None 64 | _begin_date = None 65 | _end_date = None 66 | _subject = None 67 | _subject_public_key = None 68 | _hash_algo = None 69 | _basic_constraints = None 70 | _subject_alt_name = None 71 | _key_identifier = None 72 | _authority_key_identifier = None 73 | _key_usage = None 74 | _extended_key_usage = None 75 | _crl_distribution_points = None 76 | _freshest_crl = None 77 | _authority_information_access = None 78 | _ocsp_no_check = False 79 | _other_extensions = None 80 | 81 | _special_extensions = set([ 82 | 'basic_constraints', 83 | 'subject_alt_name', 84 | 'key_identifier', 85 | 'authority_key_identifier', 86 | 'key_usage', 87 | 'extended_key_usage', 88 | 'crl_distribution_points', 89 | 'freshest_crl', 90 | 'authority_information_access', 91 | 'ocsp_no_check', 92 | ]) 93 | _deprecated_extensions = set([ 94 | 'subject_directory_attributes', 95 | 'entrust_version_extension', 96 | 'netscape_certificate_type', 97 | ]) 98 | 99 | def __init__(self, subject, subject_public_key): 100 | """ 101 | Unless changed, certificates will use SHA-256 for the signature, 102 | and will be valid from the moment created for one year. The serial 103 | number will be generated from the current time and a random number. 104 | 105 | :param subject: 106 | An asn1crypto.x509.Name object, or a dict - see the docstring 107 | for .subject for a list of valid options 108 | 109 | :param subject_public_key: 110 | An asn1crypto.keys.PublicKeyInfo object containing the public key 111 | the certificate is being issued for 112 | """ 113 | 114 | self.subject = subject 115 | self.subject_public_key = subject_public_key 116 | self.ca = False 117 | 118 | self._hash_algo = 'sha256' 119 | self._other_extensions = {} 120 | 121 | @_writer 122 | def self_signed(self, value): 123 | """ 124 | A bool - if the certificate should be self-signed. 125 | """ 126 | 127 | self._self_signed = bool(value) 128 | 129 | if self._self_signed: 130 | self._issuer = None 131 | 132 | @_writer 133 | def serial_number(self, value): 134 | """ 135 | An int representable in 160 bits or less - must uniquely identify 136 | this certificate when combined with the issuer name. 137 | """ 138 | 139 | if not isinstance(value, int_types): 140 | raise TypeError(_pretty_message( 141 | ''' 142 | serial_number must be an integer, not %s 143 | ''', 144 | _type_name(value) 145 | )) 146 | 147 | if value < 0: 148 | raise ValueError(_pretty_message( 149 | ''' 150 | serial_number must be a non-negative integer, not %s 151 | ''', 152 | repr(value) 153 | )) 154 | 155 | if len(int_to_bytes(value)) > 20: 156 | required_bits = len(int_to_bytes(value)) * 8 157 | raise ValueError(_pretty_message( 158 | ''' 159 | serial_number must be an integer that can be represented by a 160 | 160-bit number, specified requires %s 161 | ''', 162 | required_bits 163 | )) 164 | 165 | self._serial_number = value 166 | 167 | @_writer 168 | def issuer(self, value): 169 | """ 170 | An asn1crypto.x509.Certificate object of the issuer. Used to populate 171 | both the issuer field, but also the authority key identifier extension. 172 | """ 173 | 174 | is_oscrypto = isinstance(value, asymmetric.Certificate) 175 | if not isinstance(value, x509.Certificate) and not is_oscrypto: 176 | raise TypeError(_pretty_message( 177 | ''' 178 | issuer must be an instance of asn1crypto.x509.Certificate or 179 | oscrypto.asymmetric.Certificate, not %s 180 | ''', 181 | _type_name(value) 182 | )) 183 | 184 | if is_oscrypto: 185 | value = value.asn1 186 | 187 | self._issuer = value.subject 188 | 189 | self._key_identifier = self._subject_public_key.sha1 190 | self._authority_key_identifier = x509.AuthorityKeyIdentifier({ 191 | 'key_identifier': value.public_key.sha1 192 | }) 193 | 194 | @_writer 195 | def begin_date(self, value): 196 | """ 197 | A datetime.datetime object of when the certificate becomes valid. 198 | """ 199 | 200 | if not isinstance(value, datetime): 201 | raise TypeError(_pretty_message( 202 | ''' 203 | begin_date must be an instance of datetime.datetime, not %s 204 | ''', 205 | _type_name(value) 206 | )) 207 | 208 | self._begin_date = value 209 | 210 | @_writer 211 | def end_date(self, value): 212 | """ 213 | A datetime.datetime object of when the certificate is last to be 214 | considered valid. 215 | """ 216 | 217 | if not isinstance(value, datetime): 218 | raise TypeError(_pretty_message( 219 | ''' 220 | end_date must be an instance of datetime.datetime, not %s 221 | ''', 222 | _type_name(value) 223 | )) 224 | 225 | self._end_date = value 226 | 227 | @_writer 228 | def subject(self, value): 229 | """ 230 | An asn1crypto.x509.Name object, or a dict with a minimum of the 231 | following keys: 232 | 233 | - "country_name" 234 | - "state_or_province_name" 235 | - "locality_name" 236 | - "organization_name" 237 | - "common_name" 238 | 239 | Less common keys include: 240 | 241 | - "organizational_unit_name" 242 | - "email_address" 243 | - "street_address" 244 | - "postal_code" 245 | - "business_category" 246 | - "incorporation_locality" 247 | - "incorporation_state_or_province" 248 | - "incorporation_country" 249 | 250 | Uncommon keys include: 251 | 252 | - "surname" 253 | - "title" 254 | - "serial_number" 255 | - "name" 256 | - "given_name" 257 | - "initials" 258 | - "generation_qualifier" 259 | - "dn_qualifier" 260 | - "pseudonym" 261 | - "domain_component" 262 | 263 | All values should be unicode strings. 264 | """ 265 | 266 | is_dict = isinstance(value, dict) 267 | if not isinstance(value, x509.Name) and not is_dict: 268 | raise TypeError(_pretty_message( 269 | ''' 270 | subject must be an instance of asn1crypto.x509.Name or a dict, 271 | not %s 272 | ''', 273 | _type_name(value) 274 | )) 275 | 276 | if is_dict: 277 | value = x509.Name.build(value) 278 | 279 | self._subject = value 280 | 281 | @_writer 282 | def subject_public_key(self, value): 283 | """ 284 | An asn1crypto.keys.PublicKeyInfo or oscrypto.asymmetric.PublicKey 285 | object of the subject's public key. 286 | """ 287 | 288 | is_oscrypto = isinstance(value, asymmetric.PublicKey) 289 | if not isinstance(value, keys.PublicKeyInfo) and not is_oscrypto: 290 | raise TypeError(_pretty_message( 291 | ''' 292 | subject_public_key must be an instance of 293 | asn1crypto.keys.PublicKeyInfo or oscrypto.asymmetric.PublicKey, 294 | not %s 295 | ''', 296 | _type_name(value) 297 | )) 298 | 299 | if is_oscrypto: 300 | value = value.asn1 301 | 302 | self._subject_public_key = value 303 | self._key_identifier = self._subject_public_key.sha1 304 | self._authority_key_identifier = None 305 | 306 | @_writer 307 | def hash_algo(self, value): 308 | """ 309 | A unicode string of the hash algorithm to use when signing the 310 | certificate - "sha1" (not recommended), "sha256" or "sha512". 311 | """ 312 | 313 | if value not in set(['sha1', 'sha256', 'sha512']): 314 | raise ValueError(_pretty_message( 315 | ''' 316 | hash_algo must be one of "sha1", "sha256", "sha512", not %s 317 | ''', 318 | repr(value) 319 | )) 320 | 321 | self._hash_algo = value 322 | 323 | @property 324 | def ca(self): 325 | """ 326 | A bool - if the certificate is a CA cert 327 | """ 328 | 329 | return self._basic_constraints['ca'].native 330 | 331 | @ca.setter 332 | def ca(self, value): 333 | self._basic_constraints = x509.BasicConstraints({'ca': bool(value)}) 334 | 335 | if value: 336 | self._key_usage = x509.KeyUsage(set(['key_cert_sign', 'crl_sign'])) 337 | self._extended_key_usage = None 338 | else: 339 | self._key_usage = x509.KeyUsage(set(['digital_signature', 'key_encipherment'])) 340 | self._extended_key_usage = x509.ExtKeyUsageSyntax(['server_auth', 'client_auth']) 341 | 342 | @property 343 | def subject_alt_domains(self): 344 | """ 345 | A list of unicode strings - the domains in the subject alt name 346 | extension. 347 | """ 348 | 349 | return self._get_subject_alt('dns_name') 350 | 351 | @subject_alt_domains.setter 352 | def subject_alt_domains(self, value): 353 | self._set_subject_alt('dns_name', value) 354 | 355 | @property 356 | def subject_alt_emails(self): 357 | """ 358 | A list of unicode strings - the email addresses in the subject alt name 359 | extension. 360 | """ 361 | 362 | return self._get_subject_alt('rfc822_name') 363 | 364 | @subject_alt_emails.setter 365 | def subject_alt_emails(self, value): 366 | self._set_subject_alt('rfc822_name', value) 367 | 368 | @property 369 | def subject_alt_ips(self): 370 | """ 371 | A list of unicode strings - the IPs in the subject alt name extension. 372 | """ 373 | 374 | return self._get_subject_alt('ip_address') 375 | 376 | @subject_alt_ips.setter 377 | def subject_alt_ips(self, value): 378 | self._set_subject_alt('ip_address', value) 379 | 380 | @property 381 | def subject_alt_uris(self): 382 | """ 383 | A list of unicode strings - the URIs in the subject alt name extension. 384 | """ 385 | 386 | return self._get_subject_alt('uniform_resource_identifier') 387 | 388 | @subject_alt_uris.setter 389 | def subject_alt_uris(self, value): 390 | self._set_subject_alt('uniform_resource_identifier', value) 391 | 392 | def _get_subject_alt(self, name): 393 | """ 394 | Returns the native value for each value in the subject alt name 395 | extension that is an asn1crypto.x509.GeneralName of the type 396 | specified by the name param. 397 | 398 | :param name: 399 | A unicode string use to filter the x509.GeneralName objects by - 400 | is the name attribute of x509.GeneralName 401 | 402 | :return: 403 | A list of unicode strings 404 | """ 405 | 406 | if self._subject_alt_name is None: 407 | return [] 408 | 409 | output = [] 410 | for general_name in self._subject_alt_name: 411 | if general_name.name == name: 412 | output.append(general_name.native) 413 | return output 414 | 415 | def _set_subject_alt(self, name, values): 416 | """ 417 | Replaces all existing asn1crypto.x509.GeneralName objects of the 418 | choice represented by the name param with the values. 419 | 420 | :param name: 421 | A unicode string of the choice name of the x509.GeneralName object 422 | 423 | :param values: 424 | A list of unicode strings to use as the values for the new 425 | x509.GeneralName objects 426 | """ 427 | 428 | if self._subject_alt_name is not None: 429 | filtered_general_names = [] 430 | for general_name in self._subject_alt_name: 431 | if general_name.name != name: 432 | filtered_general_names.append(general_name) 433 | self._subject_alt_name = x509.GeneralNames(filtered_general_names) 434 | 435 | else: 436 | self._subject_alt_name = x509.GeneralNames() 437 | 438 | if values is not None: 439 | for value in values: 440 | new_general_name = x509.GeneralName(name=name, value=value) 441 | self._subject_alt_name.append(new_general_name) 442 | 443 | if len(self._subject_alt_name) == 0: 444 | self._subject_alt_name = None 445 | 446 | @property 447 | def key_usage(self): 448 | """ 449 | A set of unicode strings - the allowed usage of the key from the key 450 | usage extension. 451 | """ 452 | 453 | if self._key_usage is None: 454 | return set() 455 | 456 | return self._key_usage.native 457 | 458 | @key_usage.setter 459 | def key_usage(self, value): 460 | if not isinstance(value, set) and value is not None: 461 | raise TypeError(_pretty_message( 462 | ''' 463 | key_usage must be an instance of set, not %s 464 | ''', 465 | _type_name(value) 466 | )) 467 | 468 | if value == set() or value is None: 469 | self._key_usage = None 470 | else: 471 | self._key_usage = x509.KeyUsage(value) 472 | 473 | @property 474 | def extended_key_usage(self): 475 | """ 476 | A set of unicode strings - the allowed usage of the key from the 477 | extended key usage extension. 478 | """ 479 | 480 | if self._extended_key_usage is None: 481 | return set() 482 | 483 | return set(self._extended_key_usage.native) 484 | 485 | @extended_key_usage.setter 486 | def extended_key_usage(self, value): 487 | if not isinstance(value, set) and value is not None: 488 | raise TypeError(_pretty_message( 489 | ''' 490 | extended_key_usage must be an instance of set, not %s 491 | ''', 492 | _type_name(value) 493 | )) 494 | 495 | if value == set() or value is None: 496 | self._extended_key_usage = None 497 | else: 498 | self._extended_key_usage = x509.ExtKeyUsageSyntax(list(value)) 499 | 500 | @property 501 | def crl_url(self): 502 | """ 503 | Location of the certificate revocation list (CRL) for the certificate. 504 | Will be one of the following types: 505 | 506 | - None for no CRL 507 | - A unicode string of the URL to the CRL for this certificate 508 | - A 2-element tuple of (unicode string URL, 509 | asn1crypto.x509.Certificate object of CRL issuer) for an indirect 510 | CRL 511 | """ 512 | 513 | if self._crl_distribution_points is None: 514 | return None 515 | 516 | return self._get_crl_url(self._crl_distribution_points) 517 | 518 | @crl_url.setter 519 | def crl_url(self, value): 520 | self._crl_distribution_points = self._make_crl_distribution_points('crl_url', value) 521 | 522 | @property 523 | def delta_crl_url(self): 524 | """ 525 | Location of the delta CRL for the certificate. Will be one of the 526 | following types: 527 | 528 | - None for no delta CRL 529 | - A unicode string of the URL to the delta CRL for this certificate 530 | - A 2-element tuple of (unicode string URL, 531 | asn1crypto.x509.Certificate object of CRL issuer) for an indirect 532 | delta CRL 533 | """ 534 | 535 | if self._freshest_crl is None: 536 | return None 537 | 538 | return self._get_crl_url(self._freshest_crl) 539 | 540 | @delta_crl_url.setter 541 | def delta_crl_url(self, value): 542 | self._freshest_crl = self._make_crl_distribution_points('delta_crl_url', value) 543 | 544 | def _get_crl_url(self, distribution_points): 545 | """ 546 | Grabs the first URL out of a asn1crypto.x509.CRLDistributionPoints 547 | object 548 | 549 | :param distribution_points: 550 | The x509.CRLDistributionPoints object to pull the URL out of 551 | 552 | :return: 553 | A unicode string or None 554 | """ 555 | 556 | if distribution_points is None: 557 | return None 558 | 559 | for distribution_point in distribution_points: 560 | name = distribution_point['distribution_point'] 561 | if name.name == 'full_name' and name.chosen[0].name == 'uniform_resource_identifier': 562 | return name.chosen[0].chosen.native 563 | 564 | return None 565 | 566 | def _make_crl_distribution_points(self, name, value): 567 | """ 568 | Constructs an asn1crypto.x509.CRLDistributionPoints object 569 | 570 | :param name: 571 | A unicode string of the attribute name to use in exceptions 572 | 573 | :param value: 574 | Either a unicode string of a URL, or a 2-element tuple of a 575 | unicode string of a URL, plus an asn1crypto.x509.Certificate 576 | object that will be signing the CRL (for indirect CRLs). 577 | 578 | :return: 579 | None or an asn1crypto.x509.CRLDistributionPoints object 580 | """ 581 | 582 | if value is None: 583 | return None 584 | 585 | is_tuple = isinstance(value, tuple) 586 | if not is_tuple and not isinstance(value, str_cls): 587 | raise TypeError(_pretty_message( 588 | ''' 589 | %s must be a unicode string or tuple of (unicode string, 590 | asn1crypto.x509.Certificate), not %s 591 | ''', 592 | name, 593 | _type_name(value) 594 | )) 595 | 596 | issuer = None 597 | if is_tuple: 598 | if len(value) != 2: 599 | raise ValueError(_pretty_message( 600 | ''' 601 | %s must be a unicode string or 2-element tuple, not a 602 | %s-element tuple 603 | ''', 604 | name, 605 | len(value) 606 | )) 607 | 608 | if not isinstance(value[0], str_cls) or not isinstance(value[1], x509.Certificate): 609 | raise TypeError(_pretty_message( 610 | ''' 611 | %s must be a tuple of (unicode string, 612 | ans1crypto.x509.Certificate), not (%s, %s) 613 | ''', 614 | name, 615 | _type_name(value[0]), 616 | _type_name(value[1]) 617 | )) 618 | 619 | url = value[0] 620 | issuer = value[1].subject 621 | else: 622 | url = value 623 | 624 | general_names = x509.GeneralNames([ 625 | x509.GeneralName( 626 | name='uniform_resource_identifier', 627 | value=url 628 | ) 629 | ]) 630 | distribution_point_name = x509.DistributionPointName( 631 | name='full_name', 632 | value=general_names 633 | ) 634 | distribution_point = x509.DistributionPoint({ 635 | 'distribution_point': distribution_point_name 636 | }) 637 | if issuer: 638 | distribution_point['crl_issuer'] = x509.GeneralNames([ 639 | x509.GeneralName(name='directory_name', value=issuer) 640 | ]) 641 | 642 | return x509.CRLDistributionPoints([distribution_point]) 643 | 644 | @property 645 | def ocsp_url(self): 646 | """ 647 | Location of the OCSP responder for this certificate. Will be one of the 648 | following types: 649 | 650 | - None for no OCSP responder 651 | - A unicode string of the URL to the OCSP responder 652 | """ 653 | 654 | if self._authority_information_access is None: 655 | return None 656 | 657 | for ad in self._authority_information_access: 658 | if ad['access_method'].native == 'ocsp' and ad['access_location'].name == 'uniform_resource_identifier': 659 | return ad['access_location'].chosen.native 660 | 661 | return None 662 | 663 | @ocsp_url.setter 664 | def ocsp_url(self, value): 665 | if value is None: 666 | self._authority_information_access = None 667 | return 668 | 669 | if not isinstance(value, str_cls): 670 | raise TypeError(_pretty_message( 671 | ''' 672 | ocsp_url must be a unicode string, not %s 673 | ''', 674 | _type_name(value) 675 | )) 676 | 677 | access_description = x509.AccessDescription({ 678 | 'access_method': 'ocsp', 679 | 'access_location': x509.GeneralName( 680 | name='uniform_resource_identifier', 681 | value=value 682 | ) 683 | }) 684 | 685 | self._authority_information_access = x509.AuthorityInfoAccessSyntax([access_description]) 686 | 687 | @_writer 688 | def ocsp_no_check(self, value): 689 | """ 690 | A bool - if the certificate should have the OCSP no check extension. 691 | Only applicable to certificates created for signing OCSP responses. 692 | Such certificates should normally be issued for a very short period of 693 | time since they are effectively whitelisted by clients. 694 | """ 695 | 696 | if value is None: 697 | self._ocsp_no_check = None 698 | else: 699 | self._ocsp_no_check = bool(value) 700 | 701 | def set_extension(self, name, value, allow_deprecated=False): 702 | """ 703 | Sets the value for an extension using a fully constructed 704 | asn1crypto.core.Asn1Value object. Normally this should not be needed, 705 | and the convenience attributes should be sufficient. 706 | 707 | See the definition of asn1crypto.x509.Extension to determine the 708 | appropriate object type for a given extension. Extensions are marked 709 | as critical when RFC 5280 or RFC 6960 indicate so. If an extension is 710 | validly marked as critical or not (such as certificate policies and 711 | extended key usage), this class will mark it as non-critical. 712 | 713 | :param name: 714 | A unicode string of an extension id name from 715 | asn1crypto.x509.ExtensionId 716 | 717 | :param value: 718 | A value object per the specs defined by asn1crypto.x509.Extension 719 | 720 | :param allow_deprecated: 721 | A bool - indicates if deprecated extensions should be allowed 722 | """ 723 | 724 | extension = x509.Extension({ 725 | 'extn_id': name 726 | }) 727 | # We use native here to convert OIDs to meaningful names 728 | name = extension['extn_id'].native 729 | 730 | if name in self._deprecated_extensions and not allow_deprecated: 731 | raise ValueError(_pretty_message( 732 | ''' 733 | An extension of the type %s was added, however it is 734 | deprecated. Please add the parameter allow_deprecated=True to 735 | the method call. 736 | ''', 737 | name 738 | )) 739 | 740 | spec = extension.spec('extn_value') 741 | 742 | if not isinstance(value, spec) and value is not None: 743 | raise TypeError(_pretty_message( 744 | ''' 745 | value must be an instance of %s, not %s 746 | ''', 747 | _type_name(spec), 748 | _type_name(value) 749 | )) 750 | 751 | if name in self._special_extensions: 752 | setattr(self, '_%s' % name, value) 753 | else: 754 | if value is None: 755 | if name in self._other_extensions: 756 | del self._other_extensions[name] 757 | else: 758 | self._other_extensions[name] = value 759 | 760 | def _determine_critical(self, name): 761 | """ 762 | :param name: 763 | The extension to get the critical value for 764 | 765 | :return: 766 | A bool indicating the correct value of the critical flag for 767 | an extension, based on information from RFC 5280 and RFC 6960. The 768 | correct value is based on the terminology SHOULD or MUST. 769 | """ 770 | 771 | if name == 'subject_alt_name': 772 | return len(self._subject) == 0 773 | 774 | if name == 'basic_constraints': 775 | return self.ca is True 776 | 777 | return { 778 | 'subject_directory_attributes': False, 779 | 'key_identifier': False, 780 | 'key_usage': True, 781 | 'private_key_usage_period': False, 782 | 'issuer_alt_name': False, 783 | 'name_constraints': True, 784 | 'crl_distribution_points': False, 785 | # Based on example EV certificates, non-CA certs have this marked 786 | # as non-critical, most likely because existing browsers don't 787 | # seem to support policies or name constraints 788 | 'certificate_policies': False, 789 | 'policy_mappings': True, 790 | 'authority_key_identifier': False, 791 | 'policy_constraints': True, 792 | 'extended_key_usage': False, 793 | 'freshest_crl': False, 794 | 'inhibit_any_policy': True, 795 | 'authority_information_access': False, 796 | 'subject_information_access': False, 797 | 'tls_feature': False, 798 | 'ocsp_no_check': False, 799 | 'entrust_version_extension': False, 800 | 'netscape_certificate_type': False, 801 | }.get(name, False) 802 | 803 | def build(self, signing_private_key): 804 | """ 805 | Validates the certificate information, constructs the ASN.1 structure 806 | and then signs it 807 | 808 | :param signing_private_key: 809 | An asn1crypto.keys.PrivateKeyInfo or oscrypto.asymmetric.PrivateKey 810 | object for the private key to sign the certificate with. If the key 811 | is self-signed, this should be the private key that matches the 812 | public key, otherwise it needs to be the issuer's private key. 813 | 814 | :return: 815 | An asn1crypto.x509.Certificate object of the newly signed 816 | certificate 817 | """ 818 | 819 | is_oscrypto = isinstance(signing_private_key, asymmetric.PrivateKey) 820 | if not isinstance(signing_private_key, keys.PrivateKeyInfo) and not is_oscrypto: 821 | raise TypeError(_pretty_message( 822 | ''' 823 | signing_private_key must be an instance of 824 | asn1crypto.keys.PrivateKeyInfo or 825 | oscrypto.asymmetric.PrivateKey, not %s 826 | ''', 827 | _type_name(signing_private_key) 828 | )) 829 | 830 | if self._self_signed is not True and self._issuer is None: 831 | raise ValueError(_pretty_message( 832 | ''' 833 | Certificate must be self-signed, or an issuer must be specified 834 | ''' 835 | )) 836 | 837 | if self._self_signed: 838 | self._issuer = self._subject 839 | 840 | if self._serial_number is None: 841 | time_part = int_to_bytes(int(time.time())) 842 | random_part = util.rand_bytes(4) 843 | self._serial_number = int_from_bytes(time_part + random_part) 844 | 845 | if self._begin_date is None: 846 | self._begin_date = datetime.now(timezone.utc) 847 | 848 | if self._end_date is None: 849 | self._end_date = self._begin_date + timedelta(365) 850 | 851 | if not self.ca: 852 | for ca_only_extension in set(['policy_mappings', 'policy_constraints', 'inhibit_any_policy']): 853 | if ca_only_extension in self._other_extensions: 854 | raise ValueError(_pretty_message( 855 | ''' 856 | Extension %s is only valid for CA certificates 857 | ''', 858 | ca_only_extension 859 | )) 860 | 861 | signature_algo = signing_private_key.algorithm 862 | if signature_algo == 'ec': 863 | signature_algo = 'ecdsa' 864 | 865 | signature_algorithm_id = '%s_%s' % (self._hash_algo, signature_algo) 866 | 867 | # RFC 3280 4.1.2.5 868 | def _make_validity_time(dt): 869 | if dt < datetime(2050, 1, 1, tzinfo=timezone.utc): 870 | value = x509.Time(name='utc_time', value=dt) 871 | else: 872 | value = x509.Time(name='general_time', value=dt) 873 | 874 | return value 875 | 876 | def _make_extension(name, value): 877 | return { 878 | 'extn_id': name, 879 | 'critical': self._determine_critical(name), 880 | 'extn_value': value 881 | } 882 | 883 | extensions = [] 884 | for name in sorted(self._special_extensions): 885 | value = getattr(self, '_%s' % name) 886 | if name == 'ocsp_no_check': 887 | value = core.Null() if value else None 888 | if value is not None: 889 | extensions.append(_make_extension(name, value)) 890 | 891 | for name in sorted(self._other_extensions.keys()): 892 | extensions.append(_make_extension(name, self._other_extensions[name])) 893 | 894 | tbs_cert = x509.TbsCertificate({ 895 | 'version': 'v3', 896 | 'serial_number': self._serial_number, 897 | 'signature': { 898 | 'algorithm': signature_algorithm_id 899 | }, 900 | 'issuer': self._issuer, 901 | 'validity': { 902 | 'not_before': _make_validity_time(self._begin_date), 903 | 'not_after': _make_validity_time(self._end_date), 904 | }, 905 | 'subject': self._subject, 906 | 'subject_public_key_info': self._subject_public_key, 907 | 'extensions': extensions 908 | }) 909 | 910 | if signing_private_key.algorithm == 'rsa': 911 | sign_func = asymmetric.rsa_pkcs1v15_sign 912 | elif signing_private_key.algorithm == 'dsa': 913 | sign_func = asymmetric.dsa_sign 914 | elif signing_private_key.algorithm == 'ec': 915 | sign_func = asymmetric.ecdsa_sign 916 | 917 | if not is_oscrypto: 918 | signing_private_key = asymmetric.load_private_key(signing_private_key) 919 | signature = sign_func(signing_private_key, tbs_cert.dump(), self._hash_algo) 920 | 921 | return x509.Certificate({ 922 | 'tbs_certificate': tbs_cert, 923 | 'signature_algorithm': { 924 | 'algorithm': signature_algorithm_id 925 | }, 926 | 'signature_value': signature 927 | }) 928 | 929 | 930 | def _pretty_message(string, *params): 931 | """ 932 | Takes a multi-line string and does the following: 933 | 934 | - dedents 935 | - converts newlines with text before and after into a single line 936 | - strips leading and trailing whitespace 937 | 938 | :param string: 939 | The string to format 940 | 941 | :param *params: 942 | Params to interpolate into the string 943 | 944 | :return: 945 | The formatted string 946 | """ 947 | 948 | output = textwrap.dedent(string) 949 | 950 | # Unwrap lines, taking into account bulleted lists, ordered lists and 951 | # underlines consisting of = signs 952 | if output.find('\n') != -1: 953 | output = re.sub('(?<=\\S)\n(?=[^ \n\t\\d\\*\\-=])', ' ', output) 954 | 955 | if params: 956 | output = output % params 957 | 958 | output = output.strip() 959 | 960 | return output 961 | 962 | 963 | def _type_name(value): 964 | """ 965 | :param value: 966 | A value to get the object name of 967 | 968 | :return: 969 | A unicode string of the object name 970 | """ 971 | 972 | if inspect.isclass(value): 973 | cls = value 974 | else: 975 | cls = value.__class__ 976 | if cls.__module__ in set(['builtins', '__builtin__']): 977 | return cls.__name__ 978 | return '%s.%s' % (cls.__module__, cls.__name__) 979 | -------------------------------------------------------------------------------- /certbuilder/version.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | 5 | __version__ = '0.14.2' 6 | __version_info__ = (0, 14, 2) 7 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | ## 0.14.2 4 | 5 | - Updated [asn1crypto](https://github.com/wbond/asn1crypto) dependency to 6 | `0.18.1`, [oscrypto](https://github.com/wbond/oscrypto) dependency to 7 | `0.16.1`. 8 | 9 | ## 0.14.1 10 | 11 | - Fix a bug with setting the extended key usage of a CA certificate 12 | 13 | ## 0.14.0 14 | 15 | - Setting `.ca` to `True` no longer adds the `ocsp_signing` extended key usage 16 | since the Windows CryptoAPI treats that as a constraint that will be 17 | propagated down the chain 18 | 19 | ## 0.13.0 20 | 21 | - Added the `.subject_alt_emails` and `.subject_al_uris` attributes 22 | - Added explicit support for the TLS Feature extension to `.set_extension()` 23 | 24 | ## 0.12.1 25 | 26 | - Package metadata updates 27 | 28 | ## 0.12.0 29 | 30 | - Fix a bug with setting the issuer of a non-self-signed certificate 31 | 32 | ## 0.11.0 33 | 34 | - Added `pem_armor_certificate()` function 35 | - Fixed a bug adding subject alt domains/ips 36 | 37 | ## 0.10.0 38 | 39 | - Removed `CertBuilder.end_entity` attribute, just use `.ca` instead 40 | - Added Python 2.6 compatbility 41 | 42 | ## 0.9.0 43 | 44 | - Initial release 45 | -------------------------------------------------------------------------------- /dev/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | 6 | 7 | package_name = "certbuilder" 8 | 9 | other_packages = [] 10 | 11 | task_keyword_args = [] 12 | 13 | requires_oscrypto = True 14 | has_tests_package = False 15 | 16 | package_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 17 | build_root = os.path.abspath(os.path.join(package_root, '..')) 18 | 19 | md_source_map = { 20 | 'docs/api.md': ['certbuilder/__init__.py'], 21 | } 22 | 23 | definition_replacements = {} 24 | -------------------------------------------------------------------------------- /dev/_import.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import sys 5 | import os 6 | 7 | from . import build_root, package_name, package_root 8 | 9 | if sys.version_info < (3, 5): 10 | import imp 11 | else: 12 | import importlib 13 | import importlib.abc 14 | import importlib.util 15 | 16 | 17 | if sys.version_info < (3,): 18 | getcwd = os.getcwdu 19 | else: 20 | getcwd = os.getcwd 21 | 22 | 23 | if sys.version_info >= (3, 5): 24 | class ModCryptoMetaFinder(importlib.abc.MetaPathFinder): 25 | def setup(self): 26 | self.modules = {} 27 | sys.meta_path.insert(0, self) 28 | 29 | def add_module(self, package_name, package_path): 30 | if package_name not in self.modules: 31 | self.modules[package_name] = package_path 32 | 33 | def find_spec(self, fullname, path, target=None): 34 | name_parts = fullname.split('.') 35 | if name_parts[0] not in self.modules: 36 | return None 37 | 38 | package = name_parts[0] 39 | package_path = self.modules[package] 40 | 41 | fullpath = os.path.join(package_path, *name_parts[1:]) 42 | 43 | if os.path.isdir(fullpath): 44 | filename = os.path.join(fullpath, "__init__.py") 45 | submodule_locations = [fullpath] 46 | else: 47 | filename = fullpath + ".py" 48 | submodule_locations = None 49 | 50 | if not os.path.exists(filename): 51 | return None 52 | 53 | return importlib.util.spec_from_file_location( 54 | fullname, 55 | filename, 56 | loader=None, 57 | submodule_search_locations=submodule_locations 58 | ) 59 | 60 | CUSTOM_FINDER = ModCryptoMetaFinder() 61 | CUSTOM_FINDER.setup() 62 | 63 | 64 | def _import_from(mod, path, mod_dir=None, allow_error=False): 65 | """ 66 | Imports a module from a specific path 67 | 68 | :param mod: 69 | A unicode string of the module name 70 | 71 | :param path: 72 | A unicode string to the directory containing the module 73 | 74 | :param mod_dir: 75 | If the sub directory of "path" is different than the "mod" name, 76 | pass the sub directory as a unicode string 77 | 78 | :param allow_error: 79 | If an ImportError should be raised when the module can't be imported 80 | 81 | :return: 82 | None if not loaded, otherwise the module 83 | """ 84 | 85 | if mod in sys.modules: 86 | return sys.modules[mod] 87 | 88 | if mod_dir is None: 89 | full_mod = mod 90 | else: 91 | full_mod = mod_dir.replace(os.sep, '.') 92 | 93 | if mod_dir is None: 94 | mod_dir = mod.replace('.', os.sep) 95 | 96 | if not os.path.exists(path): 97 | return None 98 | 99 | source_path = os.path.join(path, mod_dir, '__init__.py') 100 | if not os.path.exists(source_path): 101 | source_path = os.path.join(path, mod_dir + '.py') 102 | 103 | if not os.path.exists(source_path): 104 | return None 105 | 106 | if os.sep in mod_dir: 107 | append, mod_dir = mod_dir.rsplit(os.sep, 1) 108 | path = os.path.join(path, append) 109 | 110 | try: 111 | if sys.version_info < (3, 5): 112 | mod_info = imp.find_module(mod_dir, [path]) 113 | return imp.load_module(mod, *mod_info) 114 | 115 | else: 116 | package = mod.split('.', 1)[0] 117 | package_dir = full_mod.split('.', 1)[0] 118 | package_path = os.path.join(path, package_dir) 119 | CUSTOM_FINDER.add_module(package, package_path) 120 | 121 | return importlib.import_module(mod) 122 | 123 | except ImportError: 124 | if allow_error: 125 | raise 126 | return None 127 | 128 | 129 | def _preload(require_oscrypto, print_info): 130 | """ 131 | Preloads asn1crypto and optionally oscrypto from a local source checkout, 132 | or from a normal install 133 | 134 | :param require_oscrypto: 135 | A bool if oscrypto needs to be preloaded 136 | 137 | :param print_info: 138 | A bool if info about asn1crypto and oscrypto should be printed 139 | """ 140 | 141 | if print_info: 142 | print('Working dir: ' + getcwd()) 143 | print('Python ' + sys.version.replace('\n', '')) 144 | 145 | asn1crypto = None 146 | oscrypto = None 147 | 148 | if require_oscrypto: 149 | # Some CI services don't use the package name for the dir 150 | if package_name == 'oscrypto': 151 | oscrypto_dir = package_root 152 | else: 153 | oscrypto_dir = os.path.join(build_root, 'oscrypto') 154 | oscrypto_tests = None 155 | if os.path.exists(oscrypto_dir): 156 | oscrypto_tests = _import_from('oscrypto_tests', oscrypto_dir, 'tests') 157 | if oscrypto_tests is None: 158 | import oscrypto_tests 159 | asn1crypto, oscrypto = oscrypto_tests.local_oscrypto() 160 | 161 | else: 162 | if package_name == 'asn1crypto': 163 | asn1crypto_dir = package_root 164 | else: 165 | asn1crypto_dir = os.path.join(build_root, 'asn1crypto') 166 | if os.path.exists(asn1crypto_dir): 167 | asn1crypto = _import_from('asn1crypto', asn1crypto_dir) 168 | if asn1crypto is None: 169 | import asn1crypto 170 | 171 | if print_info: 172 | print( 173 | '\nasn1crypto: %s, %s' % ( 174 | asn1crypto.__version__, 175 | os.path.dirname(asn1crypto.__file__) 176 | ) 177 | ) 178 | if require_oscrypto: 179 | backend = oscrypto.backend() 180 | if backend == 'openssl': 181 | from oscrypto._openssl._libcrypto import libcrypto_version 182 | backend = '%s (%s)' % (backend, libcrypto_version) 183 | 184 | print( 185 | 'oscrypto: %s, %s backend, %s' % ( 186 | oscrypto.__version__, 187 | backend, 188 | os.path.dirname(oscrypto.__file__) 189 | ) 190 | ) 191 | -------------------------------------------------------------------------------- /dev/_pep425.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | This file was originally derived from 5 | https://github.com/pypa/pip/blob/3e713708088aedb1cde32f3c94333d6e29aaf86e/src/pip/_internal/pep425tags.py 6 | 7 | The following license covers that code: 8 | 9 | Copyright (c) 2008-2018 The pip developers (see AUTHORS.txt file) 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining 12 | a copy of this software and associated documentation files (the 13 | "Software"), to deal in the Software without restriction, including 14 | without limitation the rights to use, copy, modify, merge, publish, 15 | distribute, sublicense, and/or sell copies of the Software, and to 16 | permit persons to whom the Software is furnished to do so, subject to 17 | the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be 20 | included in all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 24 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 25 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 26 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 27 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 28 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 29 | """ 30 | 31 | from __future__ import unicode_literals, division, absolute_import, print_function 32 | 33 | import sys 34 | import os 35 | import ctypes 36 | import re 37 | import platform 38 | 39 | if sys.version_info >= (2, 7): 40 | import sysconfig 41 | 42 | if sys.version_info < (3,): 43 | str_cls = unicode # noqa 44 | else: 45 | str_cls = str 46 | 47 | 48 | def _pep425_implementation(): 49 | """ 50 | :return: 51 | A 2 character unicode string of the implementation - 'cp' for cpython 52 | or 'pp' for PyPy 53 | """ 54 | 55 | return 'pp' if hasattr(sys, 'pypy_version_info') else 'cp' 56 | 57 | 58 | def _pep425_version(): 59 | """ 60 | :return: 61 | A tuple of integers representing the Python version number 62 | """ 63 | 64 | if hasattr(sys, 'pypy_version_info'): 65 | return (sys.version_info[0], sys.pypy_version_info.major, 66 | sys.pypy_version_info.minor) 67 | else: 68 | return (sys.version_info[0], sys.version_info[1]) 69 | 70 | 71 | def _pep425_supports_manylinux(): 72 | """ 73 | :return: 74 | A boolean indicating if the machine can use manylinux1 packages 75 | """ 76 | 77 | try: 78 | import _manylinux 79 | return bool(_manylinux.manylinux1_compatible) 80 | except (ImportError, AttributeError): 81 | pass 82 | 83 | # Check for glibc 2.5 84 | try: 85 | proc = ctypes.CDLL(None) 86 | gnu_get_libc_version = proc.gnu_get_libc_version 87 | gnu_get_libc_version.restype = ctypes.c_char_p 88 | 89 | ver = gnu_get_libc_version() 90 | if not isinstance(ver, str_cls): 91 | ver = ver.decode('ascii') 92 | match = re.match(r'(\d+)\.(\d+)', ver) 93 | return match and match.group(1) == '2' and int(match.group(2)) >= 5 94 | 95 | except (AttributeError): 96 | return False 97 | 98 | 99 | def _pep425_get_abi(): 100 | """ 101 | :return: 102 | A unicode string of the system abi. Will be something like: "cp27m", 103 | "cp33m", etc. 104 | """ 105 | 106 | try: 107 | soabi = sysconfig.get_config_var('SOABI') 108 | if soabi: 109 | if soabi.startswith('cpython-'): 110 | return 'cp%s' % soabi.split('-')[1] 111 | return soabi.replace('.', '_').replace('-', '_') 112 | except (IOError, NameError): 113 | pass 114 | 115 | impl = _pep425_implementation() 116 | suffix = '' 117 | if impl == 'cp': 118 | suffix += 'm' 119 | if sys.maxunicode == 0x10ffff and sys.version_info < (3, 3): 120 | suffix += 'u' 121 | return '%s%s%s' % (impl, ''.join(map(str_cls, _pep425_version())), suffix) 122 | 123 | 124 | def _pep425tags(): 125 | """ 126 | :return: 127 | A list of 3-element tuples with unicode strings or None: 128 | [0] implementation tag - cp33, pp27, cp26, py2, py2.py3 129 | [1] abi tag - cp26m, None 130 | [2] arch tag - linux_x86_64, macosx_10_10_x85_64, etc 131 | """ 132 | 133 | tags = [] 134 | 135 | versions = [] 136 | version_info = _pep425_version() 137 | major = version_info[:-1] 138 | for minor in range(version_info[-1], -1, -1): 139 | versions.append(''.join(map(str, major + (minor,)))) 140 | 141 | impl = _pep425_implementation() 142 | 143 | abis = [] 144 | abi = _pep425_get_abi() 145 | if abi: 146 | abis.append(abi) 147 | abi3 = _pep425_implementation() == 'cp' and sys.version_info >= (3,) 148 | if abi3: 149 | abis.append('abi3') 150 | abis.append('none') 151 | 152 | if sys.platform == 'darwin': 153 | plat_ver = platform.mac_ver() 154 | ver_parts = plat_ver[0].split('.') 155 | minor = int(ver_parts[1]) 156 | arch = plat_ver[2] 157 | if sys.maxsize == 2147483647: 158 | arch = 'i386' 159 | arches = [] 160 | while minor > 5: 161 | arches.append('macosx_10_%s_%s' % (minor, arch)) 162 | arches.append('macosx_10_%s_intel' % (minor,)) 163 | arches.append('macosx_10_%s_universal' % (minor,)) 164 | minor -= 1 165 | else: 166 | if sys.platform == 'win32': 167 | if 'amd64' in sys.version.lower(): 168 | arches = ['win_amd64'] 169 | else: 170 | arches = [sys.platform] 171 | elif hasattr(os, 'uname'): 172 | (plat, _, _, _, machine) = os.uname() 173 | plat = plat.lower().replace('/', '') 174 | machine.replace(' ', '_').replace('/', '_') 175 | if plat == 'linux' and sys.maxsize == 2147483647 and 'arm' not in machine: 176 | machine = 'i686' 177 | arch = '%s_%s' % (plat, machine) 178 | if _pep425_supports_manylinux(): 179 | arches = [arch.replace('linux', 'manylinux1'), arch] 180 | else: 181 | arches = [arch] 182 | 183 | for abi in abis: 184 | for arch in arches: 185 | tags.append(('%s%s' % (impl, versions[0]), abi, arch)) 186 | 187 | if abi3: 188 | for version in versions[1:]: 189 | for arch in arches: 190 | tags.append(('%s%s' % (impl, version), 'abi3', arch)) 191 | 192 | for arch in arches: 193 | tags.append(('py%s' % (versions[0][0]), 'none', arch)) 194 | 195 | tags.append(('%s%s' % (impl, versions[0]), 'none', 'any')) 196 | tags.append(('%s%s' % (impl, versions[0][0]), 'none', 'any')) 197 | 198 | for i, version in enumerate(versions): 199 | tags.append(('py%s' % (version,), 'none', 'any')) 200 | if i == 0: 201 | tags.append(('py%s' % (version[0]), 'none', 'any')) 202 | 203 | tags.append(('py2.py3', 'none', 'any')) 204 | 205 | return tags 206 | -------------------------------------------------------------------------------- /dev/_task.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import ast 5 | import _ast 6 | import os 7 | import sys 8 | 9 | from . import package_root, task_keyword_args 10 | from ._import import _import_from 11 | 12 | 13 | if sys.version_info < (3,): 14 | byte_cls = str 15 | else: 16 | byte_cls = bytes 17 | 18 | 19 | def _list_tasks(): 20 | """ 21 | Fetches a list of all valid tasks that may be run, and the args they 22 | accept. Does not actually import the task module to prevent errors if a 23 | user does not have the dependencies installed for every task. 24 | 25 | :return: 26 | A list of 2-element tuples: 27 | 0: a unicode string of the task name 28 | 1: a list of dicts containing the parameter definitions 29 | """ 30 | 31 | out = [] 32 | dev_path = os.path.join(package_root, 'dev') 33 | for fname in sorted(os.listdir(dev_path)): 34 | if fname.startswith('.') or fname.startswith('_'): 35 | continue 36 | if not fname.endswith('.py'): 37 | continue 38 | name = fname[:-3] 39 | args = () 40 | 41 | full_path = os.path.join(package_root, 'dev', fname) 42 | with open(full_path, 'rb') as f: 43 | full_code = f.read() 44 | if sys.version_info >= (3,): 45 | full_code = full_code.decode('utf-8') 46 | 47 | task_node = ast.parse(full_code, filename=full_path) 48 | for node in ast.iter_child_nodes(task_node): 49 | if isinstance(node, _ast.Assign): 50 | if len(node.targets) == 1 \ 51 | and isinstance(node.targets[0], _ast.Name) \ 52 | and node.targets[0].id == 'run_args': 53 | args = ast.literal_eval(node.value) 54 | break 55 | 56 | out.append((name, args)) 57 | return out 58 | 59 | 60 | def show_usage(): 61 | """ 62 | Prints to stderr the valid options for invoking tasks 63 | """ 64 | 65 | valid_tasks = [] 66 | for task in _list_tasks(): 67 | usage = task[0] 68 | for run_arg in task[1]: 69 | usage += ' ' 70 | name = run_arg.get('name', '') 71 | if run_arg.get('required', False): 72 | usage += '{%s}' % name 73 | else: 74 | usage += '[%s]' % name 75 | valid_tasks.append(usage) 76 | 77 | out = 'Usage: run.py' 78 | for karg in task_keyword_args: 79 | out += ' [%s=%s]' % (karg['name'], karg['placeholder']) 80 | out += ' (%s)' % ' | '.join(valid_tasks) 81 | 82 | print(out, file=sys.stderr) 83 | sys.exit(1) 84 | 85 | 86 | def _get_arg(num): 87 | """ 88 | :return: 89 | A unicode string of the requested command line arg 90 | """ 91 | 92 | if len(sys.argv) < num + 1: 93 | return None 94 | arg = sys.argv[num] 95 | if isinstance(arg, byte_cls): 96 | arg = arg.decode('utf-8') 97 | return arg 98 | 99 | 100 | def run_task(): 101 | """ 102 | Parses the command line args, invoking the requested task 103 | """ 104 | 105 | arg_num = 1 106 | task = None 107 | args = [] 108 | kwargs = {} 109 | 110 | # We look for the task name, processing any global task keyword args 111 | # by setting the appropriate env var 112 | while True: 113 | val = _get_arg(arg_num) 114 | if val is None: 115 | break 116 | 117 | next_arg = False 118 | for karg in task_keyword_args: 119 | if val.startswith(karg['name'] + '='): 120 | os.environ[karg['env_var']] = val[len(karg['name']) + 1:] 121 | next_arg = True 122 | break 123 | 124 | if next_arg: 125 | arg_num += 1 126 | continue 127 | 128 | task = val 129 | break 130 | 131 | if task is None: 132 | show_usage() 133 | 134 | task_mod = _import_from('dev.%s' % task, package_root, allow_error=True) 135 | if task_mod is None: 136 | show_usage() 137 | 138 | run_args = task_mod.__dict__.get('run_args', []) 139 | max_args = arg_num + 1 + len(run_args) 140 | 141 | if len(sys.argv) > max_args: 142 | show_usage() 143 | 144 | for i, run_arg in enumerate(run_args): 145 | val = _get_arg(arg_num + 1 + i) 146 | if val is None: 147 | if run_arg.get('required', False): 148 | show_usage() 149 | break 150 | 151 | if run_arg.get('cast') == 'int' and val.isdigit(): 152 | val = int(val) 153 | 154 | kwarg = run_arg.get('kwarg') 155 | if kwarg: 156 | kwargs[kwarg] = val 157 | else: 158 | args.append(val) 159 | 160 | run = task_mod.__dict__.get('run') 161 | 162 | result = run(*args, **kwargs) 163 | sys.exit(int(not result)) 164 | -------------------------------------------------------------------------------- /dev/api_docs.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import ast 5 | import _ast 6 | import os 7 | import re 8 | import sys 9 | import textwrap 10 | 11 | import commonmark 12 | from collections import OrderedDict 13 | 14 | from . import package_name, package_root, md_source_map, definition_replacements 15 | 16 | 17 | if hasattr(commonmark, 'DocParser'): 18 | raise EnvironmentError("commonmark must be version 0.6.0 or newer") 19 | 20 | 21 | def _get_func_info(docstring, def_lineno, code_lines, prefix): 22 | """ 23 | Extracts the function signature and description of a Python function 24 | 25 | :param docstring: 26 | A unicode string of the docstring for the function 27 | 28 | :param def_lineno: 29 | An integer line number that function was defined on 30 | 31 | :param code_lines: 32 | A list of unicode string lines from the source file the function was 33 | defined in 34 | 35 | :param prefix: 36 | A prefix to prepend to all output lines 37 | 38 | :return: 39 | A 2-element tuple: 40 | 41 | - [0] A unicode string of the function signature with a docstring of 42 | parameter info 43 | - [1] A markdown snippet of the function description 44 | """ 45 | 46 | def_index = def_lineno - 1 47 | definition = code_lines[def_index] 48 | definition = definition.rstrip() 49 | while not definition.endswith(':'): 50 | def_index += 1 51 | definition += '\n' + code_lines[def_index].rstrip() 52 | 53 | definition = textwrap.dedent(definition).rstrip(':') 54 | definition = definition.replace('\n', '\n' + prefix) 55 | 56 | description = '' 57 | found_colon = False 58 | 59 | params = '' 60 | 61 | for line in docstring.splitlines(): 62 | if line and line[0] == ':': 63 | found_colon = True 64 | if not found_colon: 65 | if description: 66 | description += '\n' 67 | description += line 68 | else: 69 | if params: 70 | params += '\n' 71 | params += line 72 | 73 | description = description.strip() 74 | description_md = '' 75 | if description: 76 | description_md = "%s%s" % (prefix, description.replace('\n', '\n' + prefix)) 77 | description_md = re.sub('\n>(\\s+)\n', '\n>\n', description_md) 78 | 79 | params = params.strip() 80 | if params: 81 | definition += (':\n%s """\n%s ' % (prefix, prefix)) 82 | definition += params.replace('\n', '\n%s ' % prefix) 83 | definition += ('\n%s """' % prefix) 84 | definition = re.sub('\n>(\\s+)\n', '\n>\n', definition) 85 | 86 | for search, replace in definition_replacements.items(): 87 | definition = definition.replace(search, replace) 88 | 89 | return (definition, description_md) 90 | 91 | 92 | def _find_sections(md_ast, sections, last, last_class, total_lines=None): 93 | """ 94 | Walks through a commonmark AST to find section headers that delineate 95 | content that should be updated by this script 96 | 97 | :param md_ast: 98 | The AST of the markdown document 99 | 100 | :param sections: 101 | A dict to store the start and end lines of a section. The key will be 102 | a two-element tuple of the section type ("class", "function", 103 | "method" or "attribute") and identifier. The values are a two-element 104 | tuple of the start and end line number in the markdown document of the 105 | section. 106 | 107 | :param last: 108 | A dict containing information about the last section header seen. 109 | Includes the keys "type_name", "identifier", "start_line". 110 | 111 | :param last_class: 112 | A unicode string of the name of the last class found - used when 113 | processing methods and attributes. 114 | 115 | :param total_lines: 116 | An integer of the total number of lines in the markdown document - 117 | used to work around a bug in the API of the Python port of commonmark 118 | """ 119 | 120 | def child_walker(node): 121 | for child, entering in node.walker(): 122 | if child == node: 123 | continue 124 | yield child, entering 125 | 126 | for child, entering in child_walker(md_ast): 127 | if child.t == 'heading': 128 | start_line = child.sourcepos[0][0] 129 | 130 | if child.level == 2: 131 | if last: 132 | sections[(last['type_name'], last['identifier'])] = (last['start_line'], start_line - 1) 133 | last.clear() 134 | 135 | if child.level in set([3, 5]): 136 | heading_elements = [] 137 | for heading_child, _ in child_walker(child): 138 | heading_elements.append(heading_child) 139 | if len(heading_elements) != 2: 140 | continue 141 | first = heading_elements[0] 142 | second = heading_elements[1] 143 | if first.t != 'code': 144 | continue 145 | if second.t != 'text': 146 | continue 147 | 148 | type_name = second.literal.strip() 149 | identifier = first.literal.strip().replace('()', '').lstrip('.') 150 | 151 | if last: 152 | sections[(last['type_name'], last['identifier'])] = (last['start_line'], start_line - 1) 153 | last.clear() 154 | 155 | if type_name == 'function': 156 | if child.level != 3: 157 | continue 158 | 159 | if type_name == 'class': 160 | if child.level != 3: 161 | continue 162 | last_class.append(identifier) 163 | 164 | if type_name in set(['method', 'attribute']): 165 | if child.level != 5: 166 | continue 167 | identifier = last_class[-1] + '.' + identifier 168 | 169 | last.update({ 170 | 'type_name': type_name, 171 | 'identifier': identifier, 172 | 'start_line': start_line, 173 | }) 174 | 175 | elif child.t == 'block_quote': 176 | find_sections(child, sections, last, last_class) 177 | 178 | if last: 179 | sections[(last['type_name'], last['identifier'])] = (last['start_line'], total_lines) 180 | 181 | 182 | find_sections = _find_sections 183 | 184 | 185 | def walk_ast(node, code_lines, sections, md_chunks): 186 | """ 187 | A callback used to walk the Python AST looking for classes, functions, 188 | methods and attributes. Generates chunks of markdown markup to replace 189 | the existing content. 190 | 191 | :param node: 192 | An _ast module node object 193 | 194 | :param code_lines: 195 | A list of unicode strings - the source lines of the Python file 196 | 197 | :param sections: 198 | A dict of markdown document sections that need to be updated. The key 199 | will be a two-element tuple of the section type ("class", "function", 200 | "method" or "attribute") and identifier. The values are a two-element 201 | tuple of the start and end line number in the markdown document of the 202 | section. 203 | 204 | :param md_chunks: 205 | A dict with keys from the sections param and the values being a unicode 206 | string containing a chunk of markdown markup. 207 | """ 208 | 209 | if isinstance(node, _ast.FunctionDef): 210 | key = ('function', node.name) 211 | if key not in sections: 212 | return 213 | 214 | docstring = ast.get_docstring(node) 215 | def_lineno = node.lineno + len(node.decorator_list) 216 | 217 | definition, description_md = _get_func_info(docstring, def_lineno, code_lines, '> ') 218 | 219 | md_chunk = textwrap.dedent(""" 220 | ### `%s()` function 221 | 222 | > ```python 223 | > %s 224 | > ``` 225 | > 226 | %s 227 | """).strip() % ( 228 | node.name, 229 | definition, 230 | description_md 231 | ) + "\n" 232 | 233 | md_chunks[key] = md_chunk.replace('>\n\n', '') 234 | 235 | elif isinstance(node, _ast.ClassDef): 236 | if ('class', node.name) not in sections: 237 | return 238 | 239 | for subnode in node.body: 240 | if isinstance(subnode, _ast.FunctionDef): 241 | node_id = node.name + '.' + subnode.name 242 | 243 | method_key = ('method', node_id) 244 | is_method = method_key in sections 245 | 246 | attribute_key = ('attribute', node_id) 247 | is_attribute = attribute_key in sections 248 | 249 | is_constructor = subnode.name == '__init__' 250 | 251 | if not is_constructor and not is_attribute and not is_method: 252 | continue 253 | 254 | docstring = ast.get_docstring(subnode) 255 | def_lineno = subnode.lineno 256 | if sys.version_info < (3, 8): 257 | def_lineno += len(subnode.decorator_list) 258 | 259 | if not docstring: 260 | continue 261 | 262 | if is_method or is_constructor: 263 | definition, description_md = _get_func_info(docstring, def_lineno, code_lines, '> > ') 264 | 265 | if is_constructor: 266 | key = ('class', node.name) 267 | 268 | class_docstring = ast.get_docstring(node) or '' 269 | class_description = textwrap.dedent(class_docstring).strip() 270 | if class_description: 271 | class_description_md = "> %s\n>" % (class_description.replace("\n", "\n> ")) 272 | else: 273 | class_description_md = '' 274 | 275 | md_chunk = textwrap.dedent(""" 276 | ### `%s()` class 277 | 278 | %s 279 | > ##### constructor 280 | > 281 | > > ```python 282 | > > %s 283 | > > ``` 284 | > > 285 | %s 286 | """).strip() % ( 287 | node.name, 288 | class_description_md, 289 | definition, 290 | description_md 291 | ) 292 | 293 | md_chunk = md_chunk.replace('\n\n\n', '\n\n') 294 | 295 | else: 296 | key = method_key 297 | 298 | md_chunk = textwrap.dedent(""" 299 | > 300 | > ##### `.%s()` method 301 | > 302 | > > ```python 303 | > > %s 304 | > > ``` 305 | > > 306 | %s 307 | """).strip() % ( 308 | subnode.name, 309 | definition, 310 | description_md 311 | ) 312 | 313 | if md_chunk[-5:] == '\n> >\n': 314 | md_chunk = md_chunk[0:-5] 315 | 316 | else: 317 | key = attribute_key 318 | 319 | description = textwrap.dedent(docstring).strip() 320 | description_md = "> > %s" % (description.replace("\n", "\n> > ")) 321 | 322 | md_chunk = textwrap.dedent(""" 323 | > 324 | > ##### `.%s` attribute 325 | > 326 | %s 327 | """).strip() % ( 328 | subnode.name, 329 | description_md 330 | ) 331 | 332 | md_chunks[key] = re.sub('[ \\t]+\n', '\n', md_chunk.rstrip()) 333 | 334 | elif isinstance(node, _ast.If): 335 | for subast in node.body: 336 | walk_ast(subast, code_lines, sections, md_chunks) 337 | for subast in node.orelse: 338 | walk_ast(subast, code_lines, sections, md_chunks) 339 | 340 | 341 | def run(): 342 | """ 343 | Looks through the docs/ dir and parses each markdown document, looking for 344 | sections to update from Python docstrings. Looks for section headers in 345 | the format: 346 | 347 | - ### `ClassName()` class 348 | - ##### `.method_name()` method 349 | - ##### `.attribute_name` attribute 350 | - ### `function_name()` function 351 | 352 | The markdown content following these section headers up until the next 353 | section header will be replaced by new markdown generated from the Python 354 | docstrings of the associated source files. 355 | 356 | By default maps docs/{name}.md to {modulename}/{name}.py. Allows for 357 | custom mapping via the md_source_map variable. 358 | """ 359 | 360 | print('Updating API docs...') 361 | 362 | md_files = [] 363 | for root, _, filenames in os.walk(os.path.join(package_root, 'docs')): 364 | for filename in filenames: 365 | if not filename.endswith('.md'): 366 | continue 367 | md_files.append(os.path.join(root, filename)) 368 | 369 | parser = commonmark.Parser() 370 | 371 | for md_file in md_files: 372 | md_file_relative = md_file[len(package_root) + 1:] 373 | if md_file_relative in md_source_map: 374 | py_files = md_source_map[md_file_relative] 375 | py_paths = [os.path.join(package_root, py_file) for py_file in py_files] 376 | else: 377 | py_files = [os.path.basename(md_file).replace('.md', '.py')] 378 | py_paths = [os.path.join(package_root, package_name, py_files[0])] 379 | 380 | if not os.path.exists(py_paths[0]): 381 | continue 382 | 383 | with open(md_file, 'rb') as f: 384 | markdown = f.read().decode('utf-8') 385 | 386 | original_markdown = markdown 387 | md_lines = list(markdown.splitlines()) 388 | md_ast = parser.parse(markdown) 389 | 390 | last_class = [] 391 | last = {} 392 | sections = OrderedDict() 393 | find_sections(md_ast, sections, last, last_class, markdown.count("\n") + 1) 394 | 395 | md_chunks = {} 396 | 397 | for index, py_file in enumerate(py_files): 398 | py_path = py_paths[index] 399 | 400 | with open(os.path.join(py_path), 'rb') as f: 401 | code = f.read().decode('utf-8') 402 | module_ast = ast.parse(code, filename=py_file) 403 | code_lines = list(code.splitlines()) 404 | 405 | for node in ast.iter_child_nodes(module_ast): 406 | walk_ast(node, code_lines, sections, md_chunks) 407 | 408 | added_lines = 0 409 | 410 | def _replace_md(key, sections, md_chunk, md_lines, added_lines): 411 | start, end = sections[key] 412 | start -= 1 413 | start += added_lines 414 | end += added_lines 415 | new_lines = md_chunk.split('\n') 416 | added_lines += len(new_lines) - (end - start) 417 | 418 | # Ensure a newline above each class header 419 | if start > 0 and md_lines[start][0:4] == '### ' and md_lines[start - 1][0:1] == '>': 420 | added_lines += 1 421 | new_lines.insert(0, '') 422 | 423 | md_lines[start:end] = new_lines 424 | return added_lines 425 | 426 | for key in sections: 427 | if key not in md_chunks: 428 | raise ValueError('No documentation found for %s' % key[1]) 429 | added_lines = _replace_md(key, sections, md_chunks[key], md_lines, added_lines) 430 | 431 | markdown = '\n'.join(md_lines).strip() + '\n' 432 | 433 | if original_markdown != markdown: 434 | with open(md_file, 'wb') as f: 435 | f.write(markdown.encode('utf-8')) 436 | 437 | 438 | if __name__ == '__main__': 439 | run() 440 | -------------------------------------------------------------------------------- /dev/build.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import tarfile 6 | import zipfile 7 | 8 | import setuptools.sandbox 9 | 10 | from . import package_root, package_name, has_tests_package 11 | from ._import import _import_from 12 | 13 | 14 | def _list_zip(filename): 15 | """ 16 | Prints all of the files in a .zip file 17 | """ 18 | 19 | zf = zipfile.ZipFile(filename, 'r') 20 | for name in zf.namelist(): 21 | print(' %s' % name) 22 | 23 | 24 | def _list_tgz(filename): 25 | """ 26 | Prints all of the files in a .tar.gz file 27 | """ 28 | 29 | tf = tarfile.open(filename, 'r:gz') 30 | for name in tf.getnames(): 31 | print(' %s' % name) 32 | 33 | 34 | def run(): 35 | """ 36 | Creates a sdist .tar.gz and a bdist_wheel --univeral .whl 37 | 38 | :return: 39 | A bool - if the packaging process was successful 40 | """ 41 | 42 | setup = os.path.join(package_root, 'setup.py') 43 | tests_root = os.path.join(package_root, 'tests') 44 | tests_setup = os.path.join(tests_root, 'setup.py') 45 | 46 | # Trying to call setuptools.sandbox.run_setup(setup, ['--version']) 47 | # resulted in a segfault, so we do this instead 48 | package_dir = os.path.join(package_root, package_name) 49 | version_mod = _import_from('%s.version' % package_name, package_dir, 'version') 50 | 51 | pkg_name_info = (package_name, version_mod.__version__) 52 | print('Building %s-%s' % pkg_name_info) 53 | 54 | sdist = '%s-%s.tar.gz' % pkg_name_info 55 | whl = '%s-%s-py2.py3-none-any.whl' % pkg_name_info 56 | setuptools.sandbox.run_setup(setup, ['-q', 'sdist']) 57 | print(' - created %s' % sdist) 58 | _list_tgz(os.path.join(package_root, 'dist', sdist)) 59 | setuptools.sandbox.run_setup(setup, ['-q', 'bdist_wheel', '--universal']) 60 | print(' - created %s' % whl) 61 | _list_zip(os.path.join(package_root, 'dist', whl)) 62 | setuptools.sandbox.run_setup(setup, ['-q', 'clean']) 63 | 64 | if has_tests_package: 65 | print('Building %s_tests-%s' % (package_name, version_mod.__version__)) 66 | 67 | tests_sdist = '%s_tests-%s.tar.gz' % pkg_name_info 68 | tests_whl = '%s_tests-%s-py2.py3-none-any.whl' % pkg_name_info 69 | setuptools.sandbox.run_setup(tests_setup, ['-q', 'sdist']) 70 | print(' - created %s' % tests_sdist) 71 | _list_tgz(os.path.join(tests_root, 'dist', tests_sdist)) 72 | setuptools.sandbox.run_setup(tests_setup, ['-q', 'bdist_wheel', '--universal']) 73 | print(' - created %s' % tests_whl) 74 | _list_zip(os.path.join(tests_root, 'dist', tests_whl)) 75 | setuptools.sandbox.run_setup(tests_setup, ['-q', 'clean']) 76 | 77 | dist_dir = os.path.join(package_root, 'dist') 78 | tests_dist_dir = os.path.join(tests_root, 'dist') 79 | os.rename( 80 | os.path.join(tests_dist_dir, tests_sdist), 81 | os.path.join(dist_dir, tests_sdist) 82 | ) 83 | os.rename( 84 | os.path.join(tests_dist_dir, tests_whl), 85 | os.path.join(dist_dir, tests_whl) 86 | ) 87 | os.rmdir(tests_dist_dir) 88 | 89 | return True 90 | -------------------------------------------------------------------------------- /dev/ci-cleanup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import shutil 6 | 7 | from . import build_root, other_packages 8 | 9 | 10 | def run(): 11 | """ 12 | Cleans up CI dependencies - used for persistent GitHub Actions 13 | Runners since they don't clean themselves up. 14 | """ 15 | 16 | print("Removing ci dependencies") 17 | deps_dir = os.path.join(build_root, 'modularcrypto-deps') 18 | if os.path.exists(deps_dir): 19 | shutil.rmtree(deps_dir, ignore_errors=True) 20 | 21 | print("Removing modularcrypto packages") 22 | for other_package in other_packages: 23 | pkg_dir = os.path.join(build_root, other_package) 24 | if os.path.exists(pkg_dir): 25 | shutil.rmtree(pkg_dir, ignore_errors=True) 26 | print() 27 | 28 | return True 29 | -------------------------------------------------------------------------------- /dev/ci-driver.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import platform 6 | import sys 7 | import subprocess 8 | 9 | 10 | run_args = [ 11 | { 12 | 'name': 'cffi', 13 | 'kwarg': 'cffi', 14 | }, 15 | { 16 | 'name': 'openssl', 17 | 'kwarg': 'openssl', 18 | }, 19 | { 20 | 'name': 'winlegacy', 21 | 'kwarg': 'winlegacy', 22 | }, 23 | ] 24 | 25 | 26 | def _write_env(env, key, value): 27 | sys.stdout.write("%s: %s\n" % (key, value)) 28 | sys.stdout.flush() 29 | if sys.version_info < (3,): 30 | env[key.encode('utf-8')] = value.encode('utf-8') 31 | else: 32 | env[key] = value 33 | 34 | 35 | def run(**_): 36 | """ 37 | Runs CI, setting various env vars 38 | 39 | :return: 40 | A bool - if the CI ran successfully 41 | """ 42 | 43 | env = os.environ.copy() 44 | options = set(sys.argv[2:]) 45 | 46 | newline = False 47 | if 'cffi' not in options: 48 | _write_env(env, 'OSCRYPTO_USE_CTYPES', 'true') 49 | newline = True 50 | if 'openssl' in options and sys.platform == 'darwin': 51 | mac_version_info = tuple(map(int, platform.mac_ver()[0].split('.')[:2])) 52 | if mac_version_info < (10, 15): 53 | _write_env(env, 'OSCRYPTO_USE_OPENSSL', '/usr/lib/libcrypto.dylib,/usr/lib/libssl.dylib') 54 | else: 55 | _write_env(env, 'OSCRYPTO_USE_OPENSSL', '/usr/lib/libcrypto.35.dylib,/usr/lib/libssl.35.dylib') 56 | newline = True 57 | if 'openssl3' in options and sys.platform == 'darwin': 58 | _write_env( 59 | env, 60 | 'OSCRYPTO_USE_OPENSSL', 61 | '/usr/local/opt/openssl@3/lib/libcrypto.dylib,/usr/local/opt/openssl@3/lib/libssl.dylib' 62 | ) 63 | if 'winlegacy' in options: 64 | _write_env(env, 'OSCRYPTO_USE_WINLEGACY', 'true') 65 | newline = True 66 | 67 | if newline: 68 | sys.stdout.write("\n") 69 | 70 | proc = subprocess.Popen( 71 | [ 72 | sys.executable, 73 | 'run.py', 74 | 'ci', 75 | ], 76 | env=env 77 | ) 78 | proc.communicate() 79 | return proc.returncode == 0 80 | -------------------------------------------------------------------------------- /dev/ci.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import site 6 | import sys 7 | 8 | from . import build_root, requires_oscrypto 9 | from ._import import _preload 10 | 11 | 12 | deps_dir = os.path.join(build_root, 'modularcrypto-deps') 13 | if os.path.exists(deps_dir): 14 | site.addsitedir(deps_dir) 15 | # In case any of the deps are installed system-wide 16 | sys.path.insert(0, deps_dir) 17 | 18 | if sys.version_info[0:2] not in [(2, 6), (3, 2)]: 19 | from .lint import run as run_lint 20 | else: 21 | run_lint = None 22 | 23 | if sys.version_info[0:2] != (3, 2): 24 | from .coverage import run as run_coverage 25 | from .coverage import coverage 26 | run_tests = None 27 | 28 | else: 29 | from .tests import run as run_tests 30 | run_coverage = None 31 | 32 | 33 | def run(): 34 | """ 35 | Runs the linter and tests 36 | 37 | :return: 38 | A bool - if the linter and tests ran successfully 39 | """ 40 | 41 | _preload(requires_oscrypto, True) 42 | 43 | if run_lint: 44 | print('') 45 | lint_result = run_lint() 46 | else: 47 | lint_result = True 48 | 49 | if run_coverage: 50 | print('\nRunning tests (via coverage.py %s)' % coverage.__version__) 51 | sys.stdout.flush() 52 | tests_result = run_coverage(ci=True) 53 | else: 54 | print('\nRunning tests') 55 | sys.stdout.flush() 56 | tests_result = run_tests(ci=True) 57 | sys.stdout.flush() 58 | 59 | return lint_result and tests_result 60 | -------------------------------------------------------------------------------- /dev/codecov.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "wbond/certbuilder", 3 | "token": "c83fa671-9161-4d5e-a003-62c6eede9bd2", 4 | "disabled": true 5 | } 6 | -------------------------------------------------------------------------------- /dev/coverage.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import cgi 5 | import codecs 6 | import coverage 7 | import json 8 | import os 9 | import unittest 10 | import re 11 | import sys 12 | import tempfile 13 | import time 14 | import platform as _plat 15 | import subprocess 16 | from fnmatch import fnmatch 17 | 18 | from . import package_name, package_root, other_packages 19 | from ._import import _import_from 20 | 21 | if sys.version_info < (3,): 22 | str_cls = unicode # noqa 23 | from urllib2 import URLError 24 | from urllib import urlencode 25 | from io import open 26 | else: 27 | str_cls = str 28 | from urllib.error import URLError 29 | from urllib.parse import urlencode 30 | 31 | if sys.version_info < (3, 7): 32 | Pattern = re._pattern_type 33 | else: 34 | Pattern = re.Pattern 35 | 36 | 37 | def run(ci=False): 38 | """ 39 | Runs the tests while measuring coverage 40 | 41 | :param ci: 42 | If coverage is being run in a CI environment - this triggers trying to 43 | run the tests for the rest of modularcrypto and uploading coverage data 44 | 45 | :return: 46 | A bool - if the tests ran successfully 47 | """ 48 | 49 | xml_report_path = os.path.join(package_root, 'coverage.xml') 50 | if os.path.exists(xml_report_path): 51 | os.unlink(xml_report_path) 52 | 53 | cov = coverage.Coverage(include='%s/*.py' % package_name) 54 | cov.start() 55 | 56 | from .tests import run as run_tests 57 | result = run_tests(ci=ci) 58 | print() 59 | 60 | if ci: 61 | suite = unittest.TestSuite() 62 | loader = unittest.TestLoader() 63 | for other_package in other_packages: 64 | for test_class in _load_package_tests(other_package): 65 | suite.addTest(loader.loadTestsFromTestCase(test_class)) 66 | 67 | if suite.countTestCases() > 0: 68 | print('Running tests from other modularcrypto packages') 69 | sys.stdout.flush() 70 | runner_result = unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite) 71 | result = runner_result.wasSuccessful() and result 72 | print() 73 | sys.stdout.flush() 74 | 75 | cov.stop() 76 | cov.save() 77 | 78 | cov.report(show_missing=False) 79 | print() 80 | sys.stdout.flush() 81 | if ci: 82 | cov.xml_report() 83 | 84 | if ci and result and os.path.exists(xml_report_path): 85 | _codecov_submit() 86 | print() 87 | 88 | return result 89 | 90 | 91 | def _load_package_tests(name): 92 | """ 93 | Load the test classes from another modularcrypto package 94 | 95 | :param name: 96 | A unicode string of the other package name 97 | 98 | :return: 99 | A list of unittest.TestCase classes of the tests for the package 100 | """ 101 | 102 | package_dir = os.path.join('..', name) 103 | if not os.path.exists(package_dir): 104 | return [] 105 | 106 | return _import_from('%s_tests' % name, package_dir, 'tests').test_classes() 107 | 108 | 109 | def _env_info(): 110 | """ 111 | :return: 112 | A two-element tuple of unicode strings. The first is the name of the 113 | environment, the second the root of the repo. The environment name 114 | will be one of: "ci-travis", "ci-circle", "ci-appveyor", 115 | "ci-github-actions", "local" 116 | """ 117 | 118 | if os.getenv('CI') == 'true' and os.getenv('TRAVIS') == 'true': 119 | return ('ci-travis', os.getenv('TRAVIS_BUILD_DIR')) 120 | 121 | if os.getenv('CI') == 'True' and os.getenv('APPVEYOR') == 'True': 122 | return ('ci-appveyor', os.getenv('APPVEYOR_BUILD_FOLDER')) 123 | 124 | if os.getenv('CI') == 'true' and os.getenv('CIRCLECI') == 'true': 125 | return ('ci-circle', os.getcwdu() if sys.version_info < (3,) else os.getcwd()) 126 | 127 | if os.getenv('GITHUB_ACTIONS') == 'true': 128 | return ('ci-github-actions', os.getenv('GITHUB_WORKSPACE')) 129 | 130 | return ('local', package_root) 131 | 132 | 133 | def _codecov_submit(): 134 | env_name, root = _env_info() 135 | 136 | try: 137 | with open(os.path.join(root, 'dev/codecov.json'), 'rb') as f: 138 | json_data = json.loads(f.read().decode('utf-8')) 139 | except (OSError, ValueError, UnicodeDecodeError, KeyError): 140 | print('error reading codecov.json') 141 | return 142 | 143 | if json_data.get('disabled'): 144 | return 145 | 146 | if env_name == 'ci-travis': 147 | # http://docs.travis-ci.com/user/environment-variables/#Default-Environment-Variables 148 | build_url = 'https://travis-ci.org/%s/jobs/%s' % (os.getenv('TRAVIS_REPO_SLUG'), os.getenv('TRAVIS_JOB_ID')) 149 | query = { 150 | 'service': 'travis', 151 | 'branch': os.getenv('TRAVIS_BRANCH'), 152 | 'build': os.getenv('TRAVIS_JOB_NUMBER'), 153 | 'pr': os.getenv('TRAVIS_PULL_REQUEST'), 154 | 'job': os.getenv('TRAVIS_JOB_ID'), 155 | 'tag': os.getenv('TRAVIS_TAG'), 156 | 'slug': os.getenv('TRAVIS_REPO_SLUG'), 157 | 'commit': os.getenv('TRAVIS_COMMIT'), 158 | 'build_url': build_url, 159 | } 160 | 161 | elif env_name == 'ci-appveyor': 162 | # http://www.appveyor.com/docs/environment-variables 163 | build_url = 'https://ci.appveyor.com/project/%s/build/%s' % ( 164 | os.getenv('APPVEYOR_REPO_NAME'), 165 | os.getenv('APPVEYOR_BUILD_VERSION') 166 | ) 167 | query = { 168 | 'service': "appveyor", 169 | 'branch': os.getenv('APPVEYOR_REPO_BRANCH'), 170 | 'build': os.getenv('APPVEYOR_JOB_ID'), 171 | 'pr': os.getenv('APPVEYOR_PULL_REQUEST_NUMBER'), 172 | 'job': '/'.join(( 173 | os.getenv('APPVEYOR_ACCOUNT_NAME'), 174 | os.getenv('APPVEYOR_PROJECT_SLUG'), 175 | os.getenv('APPVEYOR_BUILD_VERSION') 176 | )), 177 | 'tag': os.getenv('APPVEYOR_REPO_TAG_NAME'), 178 | 'slug': os.getenv('APPVEYOR_REPO_NAME'), 179 | 'commit': os.getenv('APPVEYOR_REPO_COMMIT'), 180 | 'build_url': build_url, 181 | } 182 | 183 | elif env_name == 'ci-circle': 184 | # https://circleci.com/docs/environment-variables 185 | query = { 186 | 'service': 'circleci', 187 | 'branch': os.getenv('CIRCLE_BRANCH'), 188 | 'build': os.getenv('CIRCLE_BUILD_NUM'), 189 | 'pr': os.getenv('CIRCLE_PR_NUMBER'), 190 | 'job': os.getenv('CIRCLE_BUILD_NUM') + "." + os.getenv('CIRCLE_NODE_INDEX'), 191 | 'tag': os.getenv('CIRCLE_TAG'), 192 | 'slug': os.getenv('CIRCLE_PROJECT_USERNAME') + "/" + os.getenv('CIRCLE_PROJECT_REPONAME'), 193 | 'commit': os.getenv('CIRCLE_SHA1'), 194 | 'build_url': os.getenv('CIRCLE_BUILD_URL'), 195 | } 196 | 197 | elif env_name == 'ci-github-actions': 198 | branch = '' 199 | tag = '' 200 | ref = os.getenv('GITHUB_REF', '') 201 | if ref.startswith('refs/tags/'): 202 | tag = ref[10:] 203 | elif ref.startswith('refs/heads/'): 204 | branch = ref[11:] 205 | 206 | impl = _plat.python_implementation() 207 | major, minor = _plat.python_version_tuple()[0:2] 208 | build_name = '%s %s %s.%s' % (_platform_name(), impl, major, minor) 209 | 210 | query = { 211 | 'service': 'custom', 212 | 'token': json_data['token'], 213 | 'branch': branch, 214 | 'tag': tag, 215 | 'slug': os.getenv('GITHUB_REPOSITORY'), 216 | 'commit': os.getenv('GITHUB_SHA'), 217 | 'build_url': 'https://github.com/wbond/oscrypto/commit/%s/checks' % os.getenv('GITHUB_SHA'), 218 | 'name': 'GitHub Actions %s on %s' % (build_name, os.getenv('RUNNER_OS')) 219 | } 220 | 221 | else: 222 | if not os.path.exists(os.path.join(root, '.git')): 223 | print('git repository not found, not submitting coverage data') 224 | return 225 | git_status = _git_command(['status', '--porcelain'], root) 226 | if git_status != '': 227 | print('git repository has uncommitted changes, not submitting coverage data') 228 | return 229 | 230 | branch = _git_command(['rev-parse', '--abbrev-ref', 'HEAD'], root) 231 | commit = _git_command(['rev-parse', '--verify', 'HEAD'], root) 232 | tag = _git_command(['name-rev', '--tags', '--name-only', commit], root) 233 | impl = _plat.python_implementation() 234 | major, minor = _plat.python_version_tuple()[0:2] 235 | build_name = '%s %s %s.%s' % (_platform_name(), impl, major, minor) 236 | query = { 237 | 'branch': branch, 238 | 'commit': commit, 239 | 'slug': json_data['slug'], 240 | 'token': json_data['token'], 241 | 'build': build_name, 242 | } 243 | if tag != 'undefined': 244 | query['tag'] = tag 245 | 246 | payload = 'PLATFORM=%s\n' % _platform_name() 247 | payload += 'PYTHON_VERSION=%s %s\n' % (_plat.python_version(), _plat.python_implementation()) 248 | if 'oscrypto' in sys.modules: 249 | payload += 'OSCRYPTO_BACKEND=%s\n' % sys.modules['oscrypto'].backend() 250 | payload += '<<<<<< ENV\n' 251 | 252 | for path in _list_files(root): 253 | payload += path + '\n' 254 | payload += '<<<<<< network\n' 255 | 256 | payload += '# path=coverage.xml\n' 257 | with open(os.path.join(root, 'coverage.xml'), 'r', encoding='utf-8') as f: 258 | payload += f.read() + '\n' 259 | payload += '<<<<<< EOF\n' 260 | 261 | url = 'https://codecov.io/upload/v4' 262 | headers = { 263 | 'Accept': 'text/plain' 264 | } 265 | filtered_query = {} 266 | for key in query: 267 | value = query[key] 268 | if value == '' or value is None: 269 | continue 270 | filtered_query[key] = value 271 | 272 | print('Submitting coverage info to codecov.io') 273 | info = _do_request( 274 | 'POST', 275 | url, 276 | headers, 277 | query_params=filtered_query 278 | ) 279 | 280 | encoding = info[1] or 'utf-8' 281 | text = info[2].decode(encoding).strip() 282 | parts = text.split() 283 | upload_url = parts[1] 284 | 285 | headers = { 286 | 'Content-Type': 'text/plain', 287 | 'x-amz-acl': 'public-read', 288 | 'x-amz-storage-class': 'REDUCED_REDUNDANCY' 289 | } 290 | 291 | print('Uploading coverage data to codecov.io S3 bucket') 292 | _do_request( 293 | 'PUT', 294 | upload_url, 295 | headers, 296 | data=payload.encode('utf-8') 297 | ) 298 | 299 | 300 | def _git_command(params, cwd): 301 | """ 302 | Executes a git command, returning the output 303 | 304 | :param params: 305 | A list of the parameters to pass to git 306 | 307 | :param cwd: 308 | The working directory to execute git in 309 | 310 | :return: 311 | A 2-element tuple of (stdout, stderr) 312 | """ 313 | 314 | proc = subprocess.Popen( 315 | ['git'] + params, 316 | stdout=subprocess.PIPE, 317 | stderr=subprocess.STDOUT, 318 | cwd=cwd 319 | ) 320 | stdout, stderr = proc.communicate() 321 | code = proc.wait() 322 | if code != 0: 323 | e = OSError('git exit code was non-zero') 324 | e.stdout = stdout 325 | raise e 326 | return stdout.decode('utf-8').strip() 327 | 328 | 329 | def _parse_env_var_file(data): 330 | """ 331 | Parses a basic VAR="value data" file contents into a dict 332 | 333 | :param data: 334 | A unicode string of the file data 335 | 336 | :return: 337 | A dict of parsed name/value data 338 | """ 339 | 340 | output = {} 341 | for line in data.splitlines(): 342 | line = line.strip() 343 | if not line or '=' not in line: 344 | continue 345 | parts = line.split('=') 346 | if len(parts) != 2: 347 | continue 348 | name = parts[0] 349 | value = parts[1] 350 | if len(value) > 1: 351 | if value[0] == '"' and value[-1] == '"': 352 | value = value[1:-1] 353 | output[name] = value 354 | return output 355 | 356 | 357 | def _platform_name(): 358 | """ 359 | Returns information about the current operating system and version 360 | 361 | :return: 362 | A unicode string containing the OS name and version 363 | """ 364 | 365 | if sys.platform == 'darwin': 366 | version = _plat.mac_ver()[0] 367 | _plat_ver_info = tuple(map(int, version.split('.'))) 368 | if _plat_ver_info < (10, 12): 369 | name = 'OS X' 370 | else: 371 | name = 'macOS' 372 | return '%s %s' % (name, version) 373 | 374 | elif sys.platform == 'win32': 375 | _win_ver = sys.getwindowsversion() 376 | _plat_ver_info = (_win_ver[0], _win_ver[1]) 377 | return 'Windows %s' % _plat.win32_ver()[0] 378 | 379 | elif sys.platform in ['linux', 'linux2']: 380 | if os.path.exists('/etc/os-release'): 381 | with open('/etc/os-release', 'r', encoding='utf-8') as f: 382 | pairs = _parse_env_var_file(f.read()) 383 | if 'NAME' in pairs and 'VERSION_ID' in pairs: 384 | return '%s %s' % (pairs['NAME'], pairs['VERSION_ID']) 385 | version = pairs['VERSION_ID'] 386 | elif 'PRETTY_NAME' in pairs: 387 | return pairs['PRETTY_NAME'] 388 | elif 'NAME' in pairs: 389 | return pairs['NAME'] 390 | else: 391 | raise ValueError('No suitable version info found in /etc/os-release') 392 | elif os.path.exists('/etc/lsb-release'): 393 | with open('/etc/lsb-release', 'r', encoding='utf-8') as f: 394 | pairs = _parse_env_var_file(f.read()) 395 | if 'DISTRIB_DESCRIPTION' in pairs: 396 | return pairs['DISTRIB_DESCRIPTION'] 397 | else: 398 | raise ValueError('No suitable version info found in /etc/lsb-release') 399 | else: 400 | return 'Linux' 401 | 402 | else: 403 | return '%s %s' % (_plat.system(), _plat.release()) 404 | 405 | 406 | def _list_files(root): 407 | """ 408 | Lists all of the files in a directory, taking into account any .gitignore 409 | file that is present 410 | 411 | :param root: 412 | A unicode filesystem path 413 | 414 | :return: 415 | A list of unicode strings, containing paths of all files not ignored 416 | by .gitignore with root, using relative paths 417 | """ 418 | 419 | dir_patterns, file_patterns = _gitignore(root) 420 | paths = [] 421 | prefix = os.path.abspath(root) + os.sep 422 | for base, dirs, files in os.walk(root): 423 | for d in dirs: 424 | for dir_pattern in dir_patterns: 425 | if fnmatch(d, dir_pattern): 426 | dirs.remove(d) 427 | break 428 | for f in files: 429 | skip = False 430 | for file_pattern in file_patterns: 431 | if fnmatch(f, file_pattern): 432 | skip = True 433 | break 434 | if skip: 435 | continue 436 | full_path = os.path.join(base, f) 437 | if full_path[:len(prefix)] == prefix: 438 | full_path = full_path[len(prefix):] 439 | paths.append(full_path) 440 | return sorted(paths) 441 | 442 | 443 | def _gitignore(root): 444 | """ 445 | Parses a .gitignore file and returns patterns to match dirs and files. 446 | Only basic gitignore patterns are supported. Pattern negation, ** wildcards 447 | and anchored patterns are not currently implemented. 448 | 449 | :param root: 450 | A unicode string of the path to the git repository 451 | 452 | :return: 453 | A 2-element tuple: 454 | - 0: a list of unicode strings to match against dirs 455 | - 1: a list of unicode strings to match against dirs and files 456 | """ 457 | 458 | gitignore_path = os.path.join(root, '.gitignore') 459 | 460 | dir_patterns = ['.git'] 461 | file_patterns = [] 462 | 463 | if not os.path.exists(gitignore_path): 464 | return (dir_patterns, file_patterns) 465 | 466 | with open(gitignore_path, 'r', encoding='utf-8') as f: 467 | for line in f.readlines(): 468 | line = line.strip() 469 | if not line: 470 | continue 471 | if line.startswith('#'): 472 | continue 473 | if '**' in line: 474 | raise NotImplementedError('gitignore ** wildcards are not implemented') 475 | if line.startswith('!'): 476 | raise NotImplementedError('gitignore pattern negation is not implemented') 477 | if line.startswith('/'): 478 | raise NotImplementedError('gitignore anchored patterns are not implemented') 479 | if line.startswith('\\#'): 480 | line = '#' + line[2:] 481 | if line.startswith('\\!'): 482 | line = '!' + line[2:] 483 | if line.endswith('/'): 484 | dir_patterns.append(line[:-1]) 485 | else: 486 | file_patterns.append(line) 487 | 488 | return (dir_patterns, file_patterns) 489 | 490 | 491 | def _do_request(method, url, headers, data=None, query_params=None, timeout=20): 492 | """ 493 | Performs an HTTP request 494 | 495 | :param method: 496 | A unicode string of 'POST' or 'PUT' 497 | 498 | :param url; 499 | A unicode string of the URL to request 500 | 501 | :param headers: 502 | A dict of unicode strings, where keys are header names and values are 503 | the header values. 504 | 505 | :param data: 506 | A dict of unicode strings (to be encoded as 507 | application/x-www-form-urlencoded), or a byte string of data. 508 | 509 | :param query_params: 510 | A dict of unicode keys and values to pass as query params 511 | 512 | :param timeout: 513 | An integer number of seconds to use as the timeout 514 | 515 | :return: 516 | A 3-element tuple: 517 | - 0: A unicode string of the response content-type 518 | - 1: A unicode string of the response encoding, or None 519 | - 2: A byte string of the response body 520 | """ 521 | 522 | if query_params: 523 | url += '?' + urlencode(query_params).replace('+', '%20') 524 | 525 | if isinstance(data, dict): 526 | data_bytes = {} 527 | for key in data: 528 | data_bytes[key.encode('utf-8')] = data[key].encode('utf-8') 529 | data = urlencode(data_bytes) 530 | headers['Content-Type'] = 'application/x-www-form-urlencoded' 531 | if isinstance(data, str_cls): 532 | raise TypeError('data must be a byte string') 533 | 534 | try: 535 | tempfd, tempf_path = tempfile.mkstemp('-coverage') 536 | os.write(tempfd, data or b'') 537 | os.close(tempfd) 538 | 539 | if sys.platform == 'win32': 540 | powershell_exe = os.path.join('system32\\WindowsPowerShell\\v1.0\\powershell.exe') 541 | code = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;" 542 | code += "$wc = New-Object Net.WebClient;" 543 | for key in headers: 544 | code += "$wc.Headers.add('%s','%s');" % (key, headers[key]) 545 | code += "$out = $wc.UploadFile('%s', '%s', '%s');" % (url, method, tempf_path) 546 | code += "[System.Text.Encoding]::GetEncoding('ISO-8859-1').GetString($wc.ResponseHeaders.ToByteArray())" 547 | 548 | # To properly obtain bytes, we use BitConverter to get hex dash 549 | # encoding (e.g. AE-09-3F) and they decode in python 550 | code += " + [System.BitConverter]::ToString($out);" 551 | stdout, stderr = _execute( 552 | [powershell_exe, '-Command', code], 553 | os.getcwd(), 554 | re.compile(r'Unable to connect to|TLS|Internal Server Error'), 555 | 6 556 | ) 557 | if stdout[-2:] == b'\r\n' and b'\r\n\r\n' in stdout: 558 | # An extra trailing crlf is added at the end by powershell 559 | stdout = stdout[0:-2] 560 | parts = stdout.split(b'\r\n\r\n', 1) 561 | if len(parts) == 2: 562 | stdout = parts[0] + b'\r\n\r\n' + codecs.decode(parts[1].replace(b'-', b''), 'hex_codec') 563 | 564 | else: 565 | args = [ 566 | 'curl', 567 | '--http1.1', 568 | '--connect-timeout', '5', 569 | '--request', 570 | method, 571 | '--location', 572 | '--silent', 573 | '--show-error', 574 | '--include', 575 | # Prevent curl from asking for an HTTP "100 Continue" response 576 | '--header', 'Expect:' 577 | ] 578 | for key in headers: 579 | args.append('--header') 580 | args.append("%s: %s" % (key, headers[key])) 581 | args.append('--data-binary') 582 | args.append('@%s' % tempf_path) 583 | args.append(url) 584 | stdout, stderr = _execute( 585 | args, 586 | os.getcwd(), 587 | re.compile(r'Failed to connect to|TLS|SSLRead|outstanding|cleanly|timed out'), 588 | 6 589 | ) 590 | finally: 591 | if tempf_path and os.path.exists(tempf_path): 592 | os.remove(tempf_path) 593 | 594 | if len(stderr) > 0: 595 | raise URLError("Error %sing %s:\n%s" % (method, url, stderr)) 596 | 597 | parts = stdout.split(b'\r\n\r\n', 1) 598 | if len(parts) != 2: 599 | raise URLError("Error %sing %s, response data malformed:\n%s" % (method, url, stdout)) 600 | header_block, body = parts 601 | 602 | content_type_header = None 603 | content_len_header = None 604 | for hline in header_block.decode('iso-8859-1').splitlines(): 605 | hline_parts = hline.split(':', 1) 606 | if len(hline_parts) != 2: 607 | continue 608 | name, val = hline_parts 609 | name = name.strip().lower() 610 | val = val.strip() 611 | if name == 'content-type': 612 | content_type_header = val 613 | if name == 'content-length': 614 | content_len_header = val 615 | 616 | if content_type_header is None and content_len_header != '0': 617 | raise URLError("Error %sing %s, no content-type header:\n%s" % (method, url, stdout)) 618 | 619 | if content_type_header is None: 620 | content_type = 'text/plain' 621 | encoding = 'utf-8' 622 | else: 623 | content_type, params = cgi.parse_header(content_type_header) 624 | encoding = params.get('charset') 625 | 626 | return (content_type, encoding, body) 627 | 628 | 629 | def _execute(params, cwd, retry=None, retries=0, backoff=2): 630 | """ 631 | Executes a subprocess 632 | 633 | :param params: 634 | A list of the executable and arguments to pass to it 635 | 636 | :param cwd: 637 | The working directory to execute the command in 638 | 639 | :param retry: 640 | If this string is present in stderr, or regex pattern matches stderr, retry the operation 641 | 642 | :param retries: 643 | An integer number of times to retry 644 | 645 | :return: 646 | A 2-element tuple of (stdout, stderr) 647 | """ 648 | 649 | proc = subprocess.Popen( 650 | params, 651 | stdout=subprocess.PIPE, 652 | stderr=subprocess.PIPE, 653 | cwd=cwd 654 | ) 655 | stdout, stderr = proc.communicate() 656 | code = proc.wait() 657 | if code != 0: 658 | if retry and retries > 0: 659 | stderr_str = stderr.decode('utf-8') 660 | if isinstance(retry, Pattern): 661 | if retry.search(stderr_str) is not None: 662 | time.sleep(backoff) 663 | return _execute(params, cwd, retry, retries - 1, backoff * 2) 664 | elif retry in stderr_str: 665 | time.sleep(backoff) 666 | return _execute(params, cwd, retry, retries - 1, backoff * 2) 667 | e = OSError('subprocess exit code for "%s" was %d: %s' % (' '.join(params), code, stderr)) 668 | e.stdout = stdout 669 | e.stderr = stderr 670 | raise e 671 | return (stdout, stderr) 672 | 673 | 674 | if __name__ == '__main__': 675 | _codecov_submit() 676 | -------------------------------------------------------------------------------- /dev/deps.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import subprocess 6 | import sys 7 | import shutil 8 | import re 9 | import json 10 | import tarfile 11 | import zipfile 12 | 13 | from . import package_root, build_root, other_packages 14 | from ._pep425 import _pep425tags, _pep425_implementation 15 | 16 | if sys.version_info < (3,): 17 | str_cls = unicode # noqa 18 | else: 19 | str_cls = str 20 | 21 | 22 | def run(): 23 | """ 24 | Installs required development dependencies. Uses git to checkout other 25 | modularcrypto repos for more accurate coverage data. 26 | """ 27 | 28 | deps_dir = os.path.join(build_root, 'modularcrypto-deps') 29 | if os.path.exists(deps_dir): 30 | shutil.rmtree(deps_dir, ignore_errors=True) 31 | os.mkdir(deps_dir) 32 | 33 | try: 34 | print("Staging ci dependencies") 35 | _stage_requirements(deps_dir, os.path.join(package_root, 'requires', 'ci')) 36 | 37 | print("Checking out modularcrypto packages for coverage") 38 | for other_package in other_packages: 39 | pkg_url = 'https://github.com/wbond/%s.git' % other_package 40 | pkg_dir = os.path.join(build_root, other_package) 41 | if os.path.exists(pkg_dir): 42 | print("%s is already present" % other_package) 43 | continue 44 | print("Cloning %s" % pkg_url) 45 | _execute(['git', 'clone', pkg_url], build_root) 46 | print() 47 | 48 | except (Exception): 49 | if os.path.exists(deps_dir): 50 | shutil.rmtree(deps_dir, ignore_errors=True) 51 | raise 52 | 53 | return True 54 | 55 | 56 | def _download(url, dest): 57 | """ 58 | Downloads a URL to a directory 59 | 60 | :param url: 61 | The URL to download 62 | 63 | :param dest: 64 | The path to the directory to save the file in 65 | 66 | :return: 67 | The filesystem path to the saved file 68 | """ 69 | 70 | print('Downloading %s' % url) 71 | filename = os.path.basename(url) 72 | dest_path = os.path.join(dest, filename) 73 | 74 | if sys.platform == 'win32': 75 | powershell_exe = os.path.join('system32\\WindowsPowerShell\\v1.0\\powershell.exe') 76 | code = "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;" 77 | code += "(New-Object Net.WebClient).DownloadFile('%s', '%s');" % (url, dest_path) 78 | _execute([powershell_exe, '-Command', code], dest, 'Unable to connect to') 79 | 80 | else: 81 | _execute( 82 | ['curl', '-L', '--silent', '--show-error', '-O', url], 83 | dest, 84 | 'Failed to connect to' 85 | ) 86 | 87 | return dest_path 88 | 89 | 90 | def _tuple_from_ver(version_string): 91 | """ 92 | :param version_string: 93 | A unicode dotted version string 94 | 95 | :return: 96 | A tuple of integers 97 | """ 98 | 99 | match = re.search( 100 | r'(\d+(?:\.\d+)*)' 101 | r'([-._]?(?:alpha|a|beta|b|preview|pre|c|rc)\.?\d*)?' 102 | r'(-\d+|(?:[-._]?(?:rev|r|post)\.?\d*))?' 103 | r'([-._]?dev\.?\d*)?', 104 | version_string 105 | ) 106 | if not match: 107 | return tuple() 108 | 109 | nums = tuple(map(int, match.group(1).split('.'))) 110 | 111 | pre = match.group(2) 112 | if pre: 113 | pre = pre.replace('alpha', 'a') 114 | pre = pre.replace('beta', 'b') 115 | pre = pre.replace('preview', 'rc') 116 | pre = pre.replace('pre', 'rc') 117 | pre = re.sub(r'(?= (3,): 368 | env['PYTHONPATH'] = deps_dir 369 | else: 370 | env[b'PYTHONPATH'] = deps_dir.encode('utf-8') 371 | 372 | _execute( 373 | [ 374 | sys.executable, 375 | 'setup.py', 376 | 'install', 377 | '--root=%s' % root, 378 | '--install-lib=%s' % install_lib, 379 | '--no-compile' 380 | ], 381 | setup_dir, 382 | env=env 383 | ) 384 | 385 | finally: 386 | if ar: 387 | ar.close() 388 | if staging_dir: 389 | shutil.rmtree(staging_dir) 390 | 391 | 392 | def _sort_pep440_versions(releases, include_prerelease): 393 | """ 394 | :param releases: 395 | A list of unicode string PEP 440 version numbers 396 | 397 | :param include_prerelease: 398 | A boolean indicating if prerelease versions should be included 399 | 400 | :return: 401 | A sorted generator of 2-element tuples: 402 | 0: A unicode string containing a PEP 440 version number 403 | 1: A tuple of tuples containing integers - this is the output of 404 | _tuple_from_ver() for the PEP 440 version number and is intended 405 | for comparing versions 406 | """ 407 | 408 | parsed_versions = [] 409 | for v in releases: 410 | t = _tuple_from_ver(v) 411 | if not include_prerelease and t[1][0] < 0: 412 | continue 413 | parsed_versions.append((v, t)) 414 | 415 | return sorted(parsed_versions, key=lambda v: v[1]) 416 | 417 | 418 | def _is_valid_python_version(python_version, requires_python): 419 | """ 420 | Verifies the "python_version" and "requires_python" keys from a PyPi 421 | download record are applicable to the current version of Python 422 | 423 | :param python_version: 424 | The "python_version" value from a PyPi download JSON structure. This 425 | should be one of: "py2", "py3", "py2.py3" or "source". 426 | 427 | :param requires_python: 428 | The "requires_python" value from a PyPi download JSON structure. This 429 | will be None, or a comma-separated list of conditions that must be 430 | true. Ex: ">=3.5", "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 431 | """ 432 | 433 | if python_version == "py2" and sys.version_info >= (3,): 434 | return False 435 | if python_version == "py3" and sys.version_info < (3,): 436 | return False 437 | 438 | if requires_python is not None: 439 | 440 | def _ver_tuples(ver_str): 441 | ver_str = ver_str.strip() 442 | if ver_str.endswith('.*'): 443 | ver_str = ver_str[:-2] 444 | cond_tup = tuple(map(int, ver_str.split('.'))) 445 | return (sys.version_info[:len(cond_tup)], cond_tup) 446 | 447 | for part in map(str_cls.strip, requires_python.split(',')): 448 | if part.startswith('!='): 449 | sys_tup, cond_tup = _ver_tuples(part[2:]) 450 | if sys_tup == cond_tup: 451 | return False 452 | elif part.startswith('>='): 453 | sys_tup, cond_tup = _ver_tuples(part[2:]) 454 | if sys_tup < cond_tup: 455 | return False 456 | elif part.startswith('>'): 457 | sys_tup, cond_tup = _ver_tuples(part[1:]) 458 | if sys_tup <= cond_tup: 459 | return False 460 | elif part.startswith('<='): 461 | sys_tup, cond_tup = _ver_tuples(part[2:]) 462 | if sys_tup > cond_tup: 463 | return False 464 | elif part.startswith('<'): 465 | sys_tup, cond_tup = _ver_tuples(part[1:]) 466 | if sys_tup >= cond_tup: 467 | return False 468 | elif part.startswith('=='): 469 | sys_tup, cond_tup = _ver_tuples(part[2:]) 470 | if sys_tup != cond_tup: 471 | return False 472 | 473 | return True 474 | 475 | 476 | def _locate_suitable_download(downloads): 477 | """ 478 | :param downloads: 479 | A list of dicts containing a key "url", "python_version" and 480 | "requires_python" 481 | 482 | :return: 483 | A unicode string URL, or None if not a valid release for the current 484 | version of Python 485 | """ 486 | 487 | valid_tags = _pep425tags() 488 | 489 | exe_suffix = None 490 | if sys.platform == 'win32' and _pep425_implementation() == 'cp': 491 | win_arch = 'win32' if sys.maxsize == 2147483647 else 'win-amd64' 492 | version_info = sys.version_info 493 | exe_suffix = '.%s-py%d.%d.exe' % (win_arch, version_info[0], version_info[1]) 494 | 495 | wheels = {} 496 | whl = None 497 | tar_bz2 = None 498 | tar_gz = None 499 | exe = None 500 | for download in downloads: 501 | if not _is_valid_python_version(download.get('python_version'), download.get('requires_python')): 502 | continue 503 | 504 | if exe_suffix and download['url'].endswith(exe_suffix): 505 | exe = download['url'] 506 | if download['url'].endswith('.whl'): 507 | parts = os.path.basename(download['url']).split('-') 508 | tag_impl = parts[-3] 509 | tag_abi = parts[-2] 510 | tag_arch = parts[-1].split('.')[0] 511 | wheels[(tag_impl, tag_abi, tag_arch)] = download['url'] 512 | if download['url'].endswith('.tar.bz2'): 513 | tar_bz2 = download['url'] 514 | if download['url'].endswith('.tar.gz'): 515 | tar_gz = download['url'] 516 | 517 | # Find the most-specific wheel possible 518 | for tag in valid_tags: 519 | if tag in wheels: 520 | whl = wheels[tag] 521 | break 522 | 523 | if exe_suffix and exe: 524 | url = exe 525 | elif whl: 526 | url = whl 527 | elif tar_bz2: 528 | url = tar_bz2 529 | elif tar_gz: 530 | url = tar_gz 531 | else: 532 | return None 533 | 534 | return url 535 | 536 | 537 | def _stage_requirements(deps_dir, path): 538 | """ 539 | Installs requirements without using Python to download, since 540 | different services are limiting to TLS 1.2, and older version of 541 | Python do not support that 542 | 543 | :param deps_dir: 544 | A unicode path to a temporary directory to use for downloads 545 | 546 | :param path: 547 | A unicode filesystem path to a requirements file 548 | """ 549 | 550 | packages = _parse_requires(path) 551 | for p in packages: 552 | url = None 553 | pkg = p['pkg'] 554 | pkg_sub_dir = None 555 | if p['type'] == 'url': 556 | anchor = None 557 | if '#' in pkg: 558 | pkg, anchor = pkg.split('#', 1) 559 | if '&' in anchor: 560 | parts = anchor.split('&') 561 | else: 562 | parts = [anchor] 563 | for part in parts: 564 | param, value = part.split('=') 565 | if param == 'subdirectory': 566 | pkg_sub_dir = value 567 | 568 | if pkg.endswith('.zip') or pkg.endswith('.tar.gz') or pkg.endswith('.tar.bz2') or pkg.endswith('.whl'): 569 | url = pkg 570 | else: 571 | raise Exception('Unable to install package from URL that is not an archive') 572 | else: 573 | pypi_json_url = 'https://pypi.org/pypi/%s/json' % pkg 574 | json_dest = _download(pypi_json_url, deps_dir) 575 | with open(json_dest, 'rb') as f: 576 | pkg_info = json.loads(f.read().decode('utf-8')) 577 | if os.path.exists(json_dest): 578 | os.remove(json_dest) 579 | 580 | if p['type'] == '==': 581 | if p['ver'] not in pkg_info['releases']: 582 | raise Exception('Unable to find version %s of %s' % (p['ver'], pkg)) 583 | url = _locate_suitable_download(pkg_info['releases'][p['ver']]) 584 | if not url: 585 | raise Exception('Unable to find a compatible download of %s == %s' % (pkg, p['ver'])) 586 | else: 587 | p_ver_tup = _tuple_from_ver(p['ver']) 588 | for ver_str, ver_tup in reversed(_sort_pep440_versions(pkg_info['releases'], False)): 589 | if p['type'] == '>=' and ver_tup < p_ver_tup: 590 | break 591 | url = _locate_suitable_download(pkg_info['releases'][ver_str]) 592 | if url: 593 | break 594 | if not url: 595 | if p['type'] == '>=': 596 | raise Exception('Unable to find a compatible download of %s >= %s' % (pkg, p['ver'])) 597 | else: 598 | raise Exception('Unable to find a compatible download of %s' % pkg) 599 | 600 | local_path = _download(url, deps_dir) 601 | 602 | _extract_package(deps_dir, local_path, pkg_sub_dir) 603 | 604 | os.remove(local_path) 605 | 606 | 607 | def _parse_requires(path): 608 | """ 609 | Does basic parsing of pip requirements files, to allow for 610 | using something other than Python to do actual TLS requests 611 | 612 | :param path: 613 | A path to a requirements file 614 | 615 | :return: 616 | A list of dict objects containing the keys: 617 | - 'type' ('any', 'url', '==', '>=') 618 | - 'pkg' 619 | - 'ver' (if 'type' == '==' or 'type' == '>=') 620 | """ 621 | 622 | python_version = '.'.join(map(str_cls, sys.version_info[0:2])) 623 | sys_platform = sys.platform 624 | 625 | packages = [] 626 | 627 | with open(path, 'rb') as f: 628 | contents = f.read().decode('utf-8') 629 | 630 | for line in re.split(r'\r?\n', contents): 631 | line = line.strip() 632 | if not len(line): 633 | continue 634 | if re.match(r'^\s*#', line): 635 | continue 636 | if ';' in line: 637 | package, cond = line.split(';', 1) 638 | package = package.strip() 639 | cond = cond.strip() 640 | cond = cond.replace('sys_platform', repr(sys_platform)) 641 | cond = re.sub( 642 | r'[\'"]' 643 | r'(\d+(?:\.\d+)*)' 644 | r'([-._]?(?:alpha|a|beta|b|preview|pre|c|rc)\.?\d*)?' 645 | r'(-\d+|(?:[-._]?(?:rev|r|post)\.?\d*))?' 646 | r'([-._]?dev\.?\d*)?' 647 | r'[\'"]', 648 | r'_tuple_from_ver(\g<0>)', 649 | cond 650 | ) 651 | cond = cond.replace('python_version', '_tuple_from_ver(%r)' % python_version) 652 | if not eval(cond): 653 | continue 654 | else: 655 | package = line.strip() 656 | 657 | if re.match(r'^\s*-r\s*', package): 658 | sub_req_file = re.sub(r'^\s*-r\s*', '', package) 659 | sub_req_file = os.path.abspath(os.path.join(os.path.dirname(path), sub_req_file)) 660 | packages.extend(_parse_requires(sub_req_file)) 661 | continue 662 | 663 | if re.match(r'https?://', package): 664 | packages.append({'type': 'url', 'pkg': package}) 665 | continue 666 | 667 | if '>=' in package: 668 | parts = package.split('>=') 669 | package = parts[0].strip() 670 | ver = parts[1].strip() 671 | packages.append({'type': '>=', 'pkg': package, 'ver': ver}) 672 | continue 673 | 674 | if '==' in package: 675 | parts = package.split('==') 676 | package = parts[0].strip() 677 | ver = parts[1].strip() 678 | packages.append({'type': '==', 'pkg': package, 'ver': ver}) 679 | continue 680 | 681 | if re.search(r'[^ a-zA-Z0-9\-]', package): 682 | raise Exception('Unsupported requirements format version constraint: %s' % package) 683 | 684 | packages.append({'type': 'any', 'pkg': package}) 685 | 686 | return packages 687 | 688 | 689 | def _execute(params, cwd, retry=None, env=None): 690 | """ 691 | Executes a subprocess 692 | 693 | :param params: 694 | A list of the executable and arguments to pass to it 695 | 696 | :param cwd: 697 | The working directory to execute the command in 698 | 699 | :param retry: 700 | If this string is present in stderr, retry the operation 701 | 702 | :return: 703 | A 2-element tuple of (stdout, stderr) 704 | """ 705 | 706 | proc = subprocess.Popen( 707 | params, 708 | stdout=subprocess.PIPE, 709 | stderr=subprocess.PIPE, 710 | cwd=cwd, 711 | env=env 712 | ) 713 | stdout, stderr = proc.communicate() 714 | code = proc.wait() 715 | if code != 0: 716 | if retry and retry in stderr.decode('utf-8'): 717 | return _execute(params, cwd) 718 | e = OSError('subprocess exit code for "%s" was %d: %s' % (' '.join(params), code, stderr)) 719 | e.stdout = stdout 720 | e.stderr = stderr 721 | raise e 722 | return (stdout, stderr) 723 | -------------------------------------------------------------------------------- /dev/lint.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | 6 | from . import package_name, package_root 7 | 8 | import flake8 9 | if not hasattr(flake8, '__version_info__') or flake8.__version_info__ < (3,): 10 | from flake8.engine import get_style_guide 11 | else: 12 | from flake8.api.legacy import get_style_guide 13 | 14 | 15 | def run(): 16 | """ 17 | Runs flake8 lint 18 | 19 | :return: 20 | A bool - if flake8 did not find any errors 21 | """ 22 | 23 | print('Running flake8 %s' % flake8.__version__) 24 | 25 | flake8_style = get_style_guide(config_file=os.path.join(package_root, 'tox.ini')) 26 | 27 | paths = [] 28 | for _dir in [package_name, 'dev', 'tests']: 29 | for root, _, filenames in os.walk(_dir): 30 | for filename in filenames: 31 | if not filename.endswith('.py'): 32 | continue 33 | paths.append(os.path.join(root, filename)) 34 | report = flake8_style.check_files(paths) 35 | success = report.total_errors == 0 36 | if success: 37 | print('OK') 38 | return success 39 | -------------------------------------------------------------------------------- /dev/pyenv-install.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import subprocess 6 | import sys 7 | 8 | 9 | run_args = [ 10 | { 11 | 'name': 'version', 12 | 'kwarg': 'version', 13 | }, 14 | ] 15 | 16 | 17 | def _write_env(env, key, value): 18 | sys.stdout.write("%s: %s\n" % (key, value)) 19 | sys.stdout.flush() 20 | if sys.version_info < (3,): 21 | env[key.encode('utf-8')] = value.encode('utf-8') 22 | else: 23 | env[key] = value 24 | 25 | 26 | def _shell_subproc(args): 27 | proc = subprocess.Popen( 28 | args, 29 | shell=True, 30 | stdout=subprocess.PIPE, 31 | stderr=subprocess.PIPE 32 | ) 33 | so, se = proc.communicate() 34 | stdout = so.decode('utf-8') 35 | stderr = se.decode('utf-8') 36 | return proc.returncode == 0, stdout, stderr 37 | 38 | 39 | def run(version=None): 40 | """ 41 | Installs a version of Python on Mac using pyenv 42 | 43 | :return: 44 | A bool - if Python was installed successfully 45 | """ 46 | 47 | if sys.platform == 'win32': 48 | raise ValueError('pyenv-install is not designed for Windows') 49 | 50 | if version not in set(['2.6', '2.7', '3.3']): 51 | raise ValueError('Invalid version: %r' % version) 52 | 53 | python_path = os.path.expanduser('~/.pyenv/versions/%s/bin' % version) 54 | if os.path.exists(os.path.join(python_path, 'python')): 55 | print(python_path) 56 | return True 57 | 58 | stdout = "" 59 | stderr = "" 60 | 61 | has_pyenv, _, _ = _shell_subproc('command -v pyenv') 62 | if not has_pyenv: 63 | success, stdout, stderr = _shell_subproc('brew install pyenv') 64 | if not success: 65 | print(stdout) 66 | print(stderr, file=sys.stderr) 67 | return False 68 | 69 | has_zlib, _, _ = _shell_subproc('brew list zlib') 70 | if not has_zlib: 71 | success, stdout, stderr = _shell_subproc('brew install zlib') 72 | if not success: 73 | print(stdout) 74 | print(stderr, file=sys.stderr) 75 | return False 76 | 77 | success, stdout, stderr = _shell_subproc('brew --prefix zlib') 78 | if not success: 79 | print(stdout) 80 | print(stderr, file=sys.stderr) 81 | return False 82 | zlib_prefix = stdout.strip() 83 | 84 | pyenv_script = './%s' % version 85 | try: 86 | with open(pyenv_script, 'wb') as f: 87 | if version == '2.6': 88 | contents = '#require_gcc\n' \ 89 | 'install_package "openssl-1.0.2k" "https://www.openssl.org/source/old/1.0.2/openssl-1.0.2k.tar.gz' \ 90 | '#6b3977c61f2aedf0f96367dcfb5c6e578cf37e7b8d913b4ecb6643c3cb88d8c0" mac_openssl\n' \ 91 | 'install_package "readline-8.0" "https://ftpmirror.gnu.org/readline/readline-8.0.tar.gz' \ 92 | '#e339f51971478d369f8a053a330a190781acb9864cf4c541060f12078948e461" mac_readline' \ 93 | ' --if has_broken_mac_readline\n' \ 94 | 'install_package "Python-2.6.9" "https://www.python.org/ftp/python/2.6.9/Python-2.6.9.tgz' \ 95 | '#7277b1285d8a82f374ef6ebaac85b003266f7939b3f2a24a3af52f9523ac94db" standard verify_py26' 96 | elif version == '2.7': 97 | contents = '#require_gcc\n' \ 98 | 'export PYTHON_BUILD_HOMEBREW_OPENSSL_FORMULA="openssl@1.1 openssl@1.0 openssl"\n' \ 99 | 'install_package "openssl-1.0.2q" "https://www.openssl.org/source/old/1.0.2/openssl-1.0.2q.tar.gz' \ 100 | '#5744cfcbcec2b1b48629f7354203bc1e5e9b5466998bbccc5b5fcde3b18eb684" mac_openssl ' \ 101 | '--if has_broken_mac_openssl\n' \ 102 | 'install_package "readline-8.0" "https://ftpmirror.gnu.org/readline/readline-8.0.tar.gz' \ 103 | '#e339f51971478d369f8a053a330a190781acb9864cf4c541060f12078948e461" mac_readline ' \ 104 | '--if has_broken_mac_readline\n' \ 105 | 'install_package "Python-2.7.18" "https://www.python.org/ftp/python/2.7.18/Python-2.7.18.tgz' \ 106 | '#da3080e3b488f648a3d7a4560ddee895284c3380b11d6de75edb986526b9a814" standard verify_py27 ' \ 107 | 'copy_python_gdb ensurepip\n' 108 | elif version == '3.3': 109 | contents = '#require_gcc\n' \ 110 | 'install_package "openssl-1.0.2k" "https://www.openssl.org/source/old/1.0.2/openssl-1.0.2k.tar.gz' \ 111 | '#6b3977c61f2aedf0f96367dcfb5c6e578cf37e7b8d913b4ecb6643c3cb88d8c0" mac_openssl\n' \ 112 | 'install_package "readline-8.0" "https://ftpmirror.gnu.org/readline/readline-8.0.tar.gz' \ 113 | '#e339f51971478d369f8a053a330a190781acb9864cf4c541060f12078948e461" mac_readline' \ 114 | ' --if has_broken_mac_readline\n' \ 115 | 'install_package "Python-3.3.7" "https://www.python.org/ftp/python/3.3.7/Python-3.3.7.tar.xz' \ 116 | '#85f60c327501c36bc18c33370c14d472801e6af2f901dafbba056f61685429fe" standard verify_py33' 117 | f.write(contents.encode('utf-8')) 118 | 119 | args = ['pyenv', 'install', pyenv_script] 120 | stdin = None 121 | stdin_contents = None 122 | env = os.environ.copy() 123 | 124 | _write_env(env, 'CFLAGS', '-I' + zlib_prefix + '/include') 125 | _write_env(env, 'LDFLAGS', '-L' + zlib_prefix + '/lib') 126 | 127 | if version == '2.6': 128 | _write_env(env, 'PYTHON_CONFIGURE_OPTS', '--enable-ipv6') 129 | stdin = subprocess.PIPE 130 | stdin_contents = '--- configure 2021-08-05 20:17:26.000000000 -0400\n' \ 131 | '+++ configure 2021-08-05 20:21:30.000000000 -0400\n' \ 132 | '@@ -10300,17 +10300,8 @@\n' \ 133 | ' rm -f core conftest.err conftest.$ac_objext \\\n' \ 134 | ' conftest$ac_exeext conftest.$ac_ext\n' \ 135 | ' \n' \ 136 | '-if test "$buggygetaddrinfo" = "yes"; then\n' \ 137 | '-\tif test "$ipv6" = "yes"; then\n' \ 138 | '-\t\techo \'Fatal: You must get working getaddrinfo() function.\'\n' \ 139 | '-\t\techo \' or you can specify "--disable-ipv6"\'.\n' \ 140 | '-\t\texit 1\n' \ 141 | '-\tfi\n' \ 142 | '-else\n' \ 143 | '-\n' \ 144 | ' $as_echo "#define HAVE_GETADDRINFO 1" >>confdefs.h\n' \ 145 | ' \n' \ 146 | '-fi\n' \ 147 | ' for ac_func in getnameinfo\n' \ 148 | ' do :\n' \ 149 | ' ac_fn_c_check_func "$LINENO" "getnameinfo" "ac_cv_func_getnameinfo"' 150 | stdin_contents = stdin_contents.encode('ascii') 151 | args.append('--patch') 152 | elif version == '3.3': 153 | stdin = subprocess.PIPE 154 | stdin_contents = '--- configure\n' \ 155 | '+++ configure\n' \ 156 | '@@ -3391,7 +3391,7 @@ $as_echo "#define _BSD_SOURCE 1" >>confdefs.h\n' \ 157 | ' # has no effect, don\'t bother defining them\n' \ 158 | ' Darwin/[6789].*)\n' \ 159 | ' define_xopen_source=no;;\n' \ 160 | '- Darwin/1[0-9].*)\n' \ 161 | '+ Darwin/[12][0-9].*)\n' \ 162 | ' define_xopen_source=no;;\n' \ 163 | ' # On AIX 4 and 5.1, mbstate_t is defined only when _XOPEN_SOURCE == 500 but\n' \ 164 | ' # used in wcsnrtombs() and mbsnrtowcs() even if _XOPEN_SOURCE is not defined\n' \ 165 | '--- configure.ac\n' \ 166 | '+++ configure.ac\n' \ 167 | '@@ 480,7 +480,7 @@ case $ac_sys_system/$ac_sys_release in\n' \ 168 | ' # has no effect, don\'t bother defining them\n' \ 169 | ' Darwin/@<:@6789@:>@.*)\n' \ 170 | ' define_xopen_source=no;;\n' \ 171 | '- Darwin/1@<:@0-9@:>@.*)\n' \ 172 | '+ Darwin/@<:@[12]@:>@@<:@0-9@:>@.*)\n' \ 173 | ' define_xopen_source=no;;\n' \ 174 | ' # On AIX 4 and 5.1, mbstate_t is defined only when _XOPEN_SOURCE == 500 but\n' \ 175 | ' # used in wcsnrtombs() and mbsnrtowcs() even if _XOPEN_SOURCE is not defined\n' 176 | stdin_contents = stdin_contents.encode('ascii') 177 | args.append('--patch') 178 | 179 | proc = subprocess.Popen( 180 | args, 181 | stdout=subprocess.PIPE, 182 | stderr=subprocess.PIPE, 183 | stdin=stdin, 184 | env=env 185 | ) 186 | so, se = proc.communicate(stdin_contents) 187 | stdout += so.decode('utf-8') 188 | stderr += se.decode('utf-8') 189 | 190 | if proc.returncode != 0: 191 | print(stdout) 192 | print(stderr, file=sys.stderr) 193 | return False 194 | 195 | finally: 196 | if os.path.exists(pyenv_script): 197 | os.unlink(pyenv_script) 198 | 199 | print(python_path) 200 | return True 201 | -------------------------------------------------------------------------------- /dev/python-install.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from urllib.parse import urlparse 9 | from urllib.request import urlopen 10 | 11 | 12 | run_args = [ 13 | { 14 | 'name': 'version', 15 | 'kwarg': 'version', 16 | }, 17 | { 18 | 'name': 'arch', 19 | 'kwarg': 'arch', 20 | }, 21 | ] 22 | 23 | 24 | def run(version=None, arch=None): 25 | """ 26 | Installs a version of Python on Windows 27 | 28 | :return: 29 | A bool - if Python was installed successfully 30 | """ 31 | 32 | if sys.platform != 'win32': 33 | raise ValueError('python-install is only designed for Windows') 34 | 35 | if version not in set(['2.6', '2.7', '3.3']): 36 | raise ValueError('Invalid version: %r' % version) 37 | 38 | if arch not in set(['x86', 'x64']): 39 | raise ValueError('Invalid arch: %r' % arch) 40 | 41 | if version == '2.6': 42 | if arch == 'x64': 43 | url = 'https://www.python.org/ftp/python/2.6.6/python-2.6.6.amd64.msi' 44 | else: 45 | url = 'https://www.python.org/ftp/python/2.6.6/python-2.6.6.msi' 46 | elif version == '2.7': 47 | if arch == 'x64': 48 | url = 'https://www.python.org/ftp/python/2.7.18/python-2.7.18.amd64.msi' 49 | else: 50 | url = 'https://www.python.org/ftp/python/2.7.18/python-2.7.18.msi' 51 | else: 52 | if arch == 'x64': 53 | url = 'https://www.python.org/ftp/python/3.3.5/python-3.3.5.amd64.msi' 54 | else: 55 | url = 'https://www.python.org/ftp/python/3.3.5/python-3.3.5.msi' 56 | 57 | home = os.environ.get('USERPROFILE') 58 | msi_filename = os.path.basename(urlparse(url).path) 59 | msi_path = os.path.join(home, msi_filename) 60 | install_path = os.path.join(os.environ.get('LOCALAPPDATA'), 'Python%s-%s' % (version, arch)) 61 | 62 | if os.path.exists(os.path.join(install_path, 'python.exe')): 63 | print(install_path) 64 | return True 65 | 66 | try: 67 | with urlopen(url) as r, open(msi_path, 'wb') as f: 68 | shutil.copyfileobj(r, f) 69 | 70 | proc = subprocess.Popen( 71 | 'msiexec /passive /a %s TARGETDIR=%s' % (msi_filename, install_path), 72 | shell=True, 73 | cwd=home 74 | ) 75 | proc.communicate() 76 | 77 | finally: 78 | if os.path.exists(msi_path): 79 | os.unlink(msi_path) 80 | 81 | print(install_path) 82 | return True 83 | -------------------------------------------------------------------------------- /dev/release.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import subprocess 5 | import sys 6 | 7 | import twine.cli 8 | 9 | from . import package_name, package_root, has_tests_package 10 | from .build import run as build 11 | 12 | 13 | def run(): 14 | """ 15 | Creates a sdist .tar.gz and a bdist_wheel --univeral .whl and uploads 16 | them to pypi 17 | 18 | :return: 19 | A bool - if the packaging and upload process was successful 20 | """ 21 | 22 | git_wc_proc = subprocess.Popen( 23 | ['git', 'status', '--porcelain', '-uno'], 24 | stdout=subprocess.PIPE, 25 | stderr=subprocess.STDOUT, 26 | cwd=package_root 27 | ) 28 | git_wc_status, _ = git_wc_proc.communicate() 29 | 30 | if len(git_wc_status) > 0: 31 | print(git_wc_status.decode('utf-8').rstrip(), file=sys.stderr) 32 | print('Unable to perform release since working copy is not clean', file=sys.stderr) 33 | return False 34 | 35 | git_tag_proc = subprocess.Popen( 36 | ['git', 'tag', '-l', '--contains', 'HEAD'], 37 | stdout=subprocess.PIPE, 38 | stderr=subprocess.PIPE, 39 | cwd=package_root 40 | ) 41 | tag, tag_error = git_tag_proc.communicate() 42 | 43 | if len(tag_error) > 0: 44 | print(tag_error.decode('utf-8').rstrip(), file=sys.stderr) 45 | print('Error looking for current git tag', file=sys.stderr) 46 | return False 47 | 48 | if len(tag) == 0: 49 | print('No git tag found on HEAD', file=sys.stderr) 50 | return False 51 | 52 | tag = tag.decode('ascii').strip() 53 | 54 | build() 55 | 56 | twine.cli.dispatch(['upload', 'dist/%s-%s*' % (package_name, tag)]) 57 | if has_tests_package: 58 | twine.cli.dispatch(['upload', 'dist/%s_tests-%s*' % (package_name, tag)]) 59 | 60 | return True 61 | -------------------------------------------------------------------------------- /dev/tests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import unittest 5 | import re 6 | import sys 7 | import warnings 8 | 9 | from . import requires_oscrypto 10 | from ._import import _preload 11 | 12 | from tests import test_classes 13 | 14 | if sys.version_info < (3,): 15 | range = xrange # noqa 16 | from cStringIO import StringIO 17 | else: 18 | from io import StringIO 19 | 20 | 21 | run_args = [ 22 | { 23 | 'name': 'regex', 24 | 'kwarg': 'matcher', 25 | }, 26 | { 27 | 'name': 'repeat_count', 28 | 'kwarg': 'repeat', 29 | 'cast': 'int', 30 | }, 31 | ] 32 | 33 | 34 | def run(matcher=None, repeat=1, ci=False): 35 | """ 36 | Runs the tests 37 | 38 | :param matcher: 39 | A unicode string containing a regular expression to use to filter test 40 | names by. A value of None will cause no filtering. 41 | 42 | :param repeat: 43 | An integer - the number of times to run the tests 44 | 45 | :param ci: 46 | A bool, indicating if the tests are being run as part of CI 47 | 48 | :return: 49 | A bool - if the tests succeeded 50 | """ 51 | 52 | _preload(requires_oscrypto, not ci) 53 | 54 | warnings.filterwarnings("error") 55 | 56 | loader = unittest.TestLoader() 57 | # We have to manually track the list of applicable tests because for 58 | # some reason with Python 3.4 on Windows, the tests in a suite are replaced 59 | # with None after being executed. This breaks the repeat functionality. 60 | test_list = [] 61 | for test_class in test_classes(): 62 | if matcher: 63 | names = loader.getTestCaseNames(test_class) 64 | for name in names: 65 | if re.search(matcher, name): 66 | test_list.append(test_class(name)) 67 | else: 68 | test_list.append(loader.loadTestsFromTestCase(test_class)) 69 | 70 | stream = sys.stdout 71 | verbosity = 1 72 | if matcher and repeat == 1: 73 | verbosity = 2 74 | elif repeat > 1: 75 | stream = StringIO() 76 | 77 | for _ in range(0, repeat): 78 | suite = unittest.TestSuite() 79 | for test in test_list: 80 | suite.addTest(test) 81 | result = unittest.TextTestRunner(stream=stream, verbosity=verbosity).run(suite) 82 | 83 | if len(result.errors) > 0 or len(result.failures) > 0: 84 | if repeat > 1: 85 | print(stream.getvalue()) 86 | return False 87 | 88 | if repeat > 1: 89 | stream.truncate(0) 90 | 91 | return True 92 | -------------------------------------------------------------------------------- /dev/version.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import codecs 5 | import os 6 | import re 7 | 8 | from . import package_root, package_name, has_tests_package 9 | 10 | 11 | run_args = [ 12 | { 13 | 'name': 'pep440_version', 14 | 'required': True 15 | }, 16 | ] 17 | 18 | 19 | def run(new_version): 20 | """ 21 | Updates the package version in the various locations 22 | 23 | :param new_version: 24 | A unicode string of the new library version as a PEP 440 version 25 | 26 | :return: 27 | A bool - if the version number was successfully bumped 28 | """ 29 | 30 | # We use a restricted form of PEP 440 versions 31 | version_match = re.match( 32 | r'(\d+)\.(\d+)\.(\d)+(?:\.((?:dev|a|b|rc)\d+))?$', 33 | new_version 34 | ) 35 | if not version_match: 36 | raise ValueError('Invalid PEP 440 version: %s' % new_version) 37 | 38 | new_version_info = ( 39 | int(version_match.group(1)), 40 | int(version_match.group(2)), 41 | int(version_match.group(3)), 42 | ) 43 | if version_match.group(4): 44 | new_version_info += (version_match.group(4),) 45 | 46 | version_path = os.path.join(package_root, package_name, 'version.py') 47 | setup_path = os.path.join(package_root, 'setup.py') 48 | setup_tests_path = os.path.join(package_root, 'tests', 'setup.py') 49 | tests_path = os.path.join(package_root, 'tests', '__init__.py') 50 | 51 | file_paths = [version_path, setup_path] 52 | if has_tests_package: 53 | file_paths.extend([setup_tests_path, tests_path]) 54 | 55 | for file_path in file_paths: 56 | orig_source = '' 57 | with codecs.open(file_path, 'r', encoding='utf-8') as f: 58 | orig_source = f.read() 59 | 60 | found = 0 61 | new_source = '' 62 | for line in orig_source.splitlines(True): 63 | if line.startswith('__version__ = '): 64 | found += 1 65 | new_source += '__version__ = %r\n' % new_version 66 | elif line.startswith('__version_info__ = '): 67 | found += 1 68 | new_source += '__version_info__ = %r\n' % (new_version_info,) 69 | elif line.startswith('PACKAGE_VERSION = '): 70 | found += 1 71 | new_source += 'PACKAGE_VERSION = %r\n' % new_version 72 | else: 73 | new_source += line 74 | 75 | if found == 0: 76 | raise ValueError('Did not find any versions in %s' % file_path) 77 | 78 | s = 's' if found > 1 else '' 79 | rel_path = file_path[len(package_root) + 1:] 80 | was_were = 'was' if found == 1 else 'were' 81 | if new_source != orig_source: 82 | print('Updated %d version%s in %s' % (found, s, rel_path)) 83 | with codecs.open(file_path, 'w', encoding='utf-8') as f: 84 | f.write(new_source) 85 | else: 86 | print('%d version%s in %s %s up-to-date' % (found, s, rel_path, was_were)) 87 | 88 | return True 89 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # certbuilder API Documentation 2 | 3 | ### `pem_armor_certificate()` function 4 | 5 | > ```python 6 | > def pem_armor_certificate(certificate): 7 | > """ 8 | > :param certificate: 9 | > An asn1crypto.x509.Certificate object of the certificate to armor. 10 | > Typically this is obtained from CertificateBuilder.build(). 11 | > 12 | > :return: 13 | > A byte string of the PEM-encoded certificate 14 | > """ 15 | > ``` 16 | > 17 | > Encodes a certificate into PEM format 18 | 19 | ### `CertificateBuilder()` class 20 | 21 | > ##### constructor 22 | > 23 | > > ```python 24 | > > def __init__(self, subject, subject_public_key): 25 | > > """ 26 | > > :param subject: 27 | > > An asn1crypto.x509.Name object, or a dict - see the docstring 28 | > > for .subject for a list of valid options 29 | > > 30 | > > :param subject_public_key: 31 | > > An asn1crypto.keys.PublicKeyInfo object containing the public key 32 | > > the certificate is being issued for 33 | > > """ 34 | > > ``` 35 | > > 36 | > > Unless changed, certificates will use SHA-256 for the signature, 37 | > > and will be valid from the moment created for one year. The serial 38 | > > number will be generated from the current time and a random number. 39 | > 40 | > ##### `.self_signed` attribute 41 | > 42 | > > A bool - if the certificate should be self-signed. 43 | > 44 | > ##### `.serial_number` attribute 45 | > 46 | > > An int representable in 160 bits or less - must uniquely identify 47 | > > this certificate when combined with the issuer name. 48 | > 49 | > ##### `.issuer` attribute 50 | > 51 | > > An asn1crypto.x509.Certificate object of the issuer. Used to populate 52 | > > both the issuer field, but also the authority key identifier extension. 53 | > 54 | > ##### `.begin_date` attribute 55 | > 56 | > > A datetime.datetime object of when the certificate becomes valid. 57 | > 58 | > ##### `.end_date` attribute 59 | > 60 | > > A datetime.datetime object of when the certificate is last to be 61 | > > considered valid. 62 | > 63 | > ##### `.subject` attribute 64 | > 65 | > > An asn1crypto.x509.Name object, or a dict with a minimum of the 66 | > > following keys: 67 | > > 68 | > > - "country_name" 69 | > > - "state_or_province_name" 70 | > > - "locality_name" 71 | > > - "organization_name" 72 | > > - "common_name" 73 | > > 74 | > > Less common keys include: 75 | > > 76 | > > - "organizational_unit_name" 77 | > > - "email_address" 78 | > > - "street_address" 79 | > > - "postal_code" 80 | > > - "business_category" 81 | > > - "incorporation_locality" 82 | > > - "incorporation_state_or_province" 83 | > > - "incorporation_country" 84 | > > 85 | > > Uncommon keys include: 86 | > > 87 | > > - "surname" 88 | > > - "title" 89 | > > - "serial_number" 90 | > > - "name" 91 | > > - "given_name" 92 | > > - "initials" 93 | > > - "generation_qualifier" 94 | > > - "dn_qualifier" 95 | > > - "pseudonym" 96 | > > - "domain_component" 97 | > > 98 | > > All values should be unicode strings. 99 | > 100 | > ##### `.subject_public_key` attribute 101 | > 102 | > > An asn1crypto.keys.PublicKeyInfo or oscrypto.asymmetric.PublicKey 103 | > > object of the subject's public key. 104 | > 105 | > ##### `.hash_algo` attribute 106 | > 107 | > > A unicode string of the hash algorithm to use when signing the 108 | > > certificate - "sha1" (not recommended), "sha256" or "sha512". 109 | > 110 | > ##### `.ca` attribute 111 | > 112 | > > A bool - if the certificate is a CA cert 113 | > 114 | > ##### `.subject_alt_domains` attribute 115 | > 116 | > > A list of unicode strings - the domains in the subject alt name 117 | > > extension. 118 | > 119 | > ##### `.subject_alt_emails` attribute 120 | > 121 | > > A list of unicode strings - the email addresses in the subject alt name 122 | > > extension. 123 | > 124 | > ##### `.subject_alt_ips` attribute 125 | > 126 | > > A list of unicode strings - the IPs in the subject alt name extension. 127 | > 128 | > ##### `.subject_alt_uris` attribute 129 | > 130 | > > A list of unicode strings - the URIs in the subject alt name extension. 131 | > 132 | > ##### `.key_usage` attribute 133 | > 134 | > > A set of unicode strings - the allowed usage of the key from the key 135 | > > usage extension. 136 | > 137 | > ##### `.extended_key_usage` attribute 138 | > 139 | > > A set of unicode strings - the allowed usage of the key from the 140 | > > extended key usage extension. 141 | > 142 | > ##### `.crl_url` attribute 143 | > 144 | > > Location of the certificate revocation list (CRL) for the certificate. 145 | > > Will be one of the following types: 146 | > > 147 | > > - None for no CRL 148 | > > - A unicode string of the URL to the CRL for this certificate 149 | > > - A 2-element tuple of (unicode string URL, 150 | > > asn1crypto.x509.Certificate object of CRL issuer) for an indirect 151 | > > CRL 152 | > 153 | > ##### `.delta_crl_url` attribute 154 | > 155 | > > Location of the delta CRL for the certificate. Will be one of the 156 | > > following types: 157 | > > 158 | > > - None for no delta CRL 159 | > > - A unicode string of the URL to the delta CRL for this certificate 160 | > > - A 2-element tuple of (unicode string URL, 161 | > > asn1crypto.x509.Certificate object of CRL issuer) for an indirect 162 | > > delta CRL 163 | > 164 | > ##### `.ocsp_url` attribute 165 | > 166 | > > Location of the OCSP responder for this certificate. Will be one of the 167 | > > following types: 168 | > > 169 | > > - None for no OCSP responder 170 | > > - A unicode string of the URL to the OCSP responder 171 | > 172 | > ##### `.ocsp_no_check` attribute 173 | > 174 | > > A bool - if the certificate should have the OCSP no check extension. 175 | > > Only applicable to certificates created for signing OCSP responses. 176 | > > Such certificates should normally be issued for a very short period of 177 | > > time since they are effectively whitelisted by clients. 178 | > 179 | > ##### `.set_extension()` method 180 | > 181 | > > ```python 182 | > > def set_extension(self, name, value, allow_deprecated=False): 183 | > > """ 184 | > > :param name: 185 | > > A unicode string of an extension id name from 186 | > > asn1crypto.x509.ExtensionId 187 | > > 188 | > > :param value: 189 | > > A value object per the specs defined by asn1crypto.x509.Extension 190 | > > 191 | > > :param allow_deprecated: 192 | > > A bool - indicates if deprecated extensions should be allowed 193 | > > """ 194 | > > ``` 195 | > > 196 | > > Sets the value for an extension using a fully constructed 197 | > > asn1crypto.core.Asn1Value object. Normally this should not be needed, 198 | > > and the convenience attributes should be sufficient. 199 | > > 200 | > > See the definition of asn1crypto.x509.Extension to determine the 201 | > > appropriate object type for a given extension. Extensions are marked 202 | > > as critical when RFC 5280 or RFC 6960 indicate so. If an extension is 203 | > > validly marked as critical or not (such as certificate policies and 204 | > > extended key usage), this class will mark it as non-critical. 205 | > 206 | > ##### `.build()` method 207 | > 208 | > > ```python 209 | > > def build(self, signing_private_key): 210 | > > """ 211 | > > :param signing_private_key: 212 | > > An asn1crypto.keys.PrivateKeyInfo or oscrypto.asymmetric.PrivateKey 213 | > > object for the private key to sign the certificate with. If the key 214 | > > is self-signed, this should be the private key that matches the 215 | > > public key, otherwise it needs to be the issuer's private key. 216 | > > 217 | > > :return: 218 | > > An asn1crypto.x509.Certificate object of the newly signed 219 | > > certificate 220 | > > """ 221 | > > ``` 222 | > > 223 | > > Validates the certificate information, constructs the ASN.1 structure 224 | > > and then signs it 225 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # certbuilder Documentation 2 | 3 | *certbuilder* is a Python library for constructing X.509 certificates. It 4 | provides a high-level interface with knowledge of RFC 5280 to produce, valid, 5 | correct certificates without terrible APIs or hunting through RFCs. 6 | 7 | Since its only dependencies are the 8 | [*asn1crypto*](https://github.com/wbond/asn1crypto#readme) and 9 | [*oscrypto*](https://github.com/wbond/oscrypto#readme) libraries, it is 10 | easy to install and use on Windows, OS X, Linux and the BSDs. 11 | 12 | The documentation consists of the following topics: 13 | 14 | - [Basic Usage](#basic-usage) 15 | - [CA and End-Entity Certificates](#ca-and-end-entity-certificates) 16 | - [API Documentation](api.md) 17 | 18 | ## Basic Usage 19 | 20 | A simple, self-signed certificate can be created by generating a public/private 21 | key pair using *oscrypto* and then passing a dictionary of name information to 22 | the `CertificateBuilder()` constructor: 23 | 24 | ```python 25 | from oscrypto import asymmetric 26 | from certbuilder import CertificateBuilder, pem_armor_certificate 27 | 28 | 29 | public_key, private_key = asymmetric.generate_pair('rsa', bit_size=2048) 30 | 31 | with open('/path/to/my/env/will_bond.key', 'wb') as f: 32 | f.write(asymmetric.dump_private_key(private_key, 'password')) 33 | 34 | builder = CertificateBuilder( 35 | { 36 | 'country_name': 'US', 37 | 'state_or_province_name': 'Massachusetts', 38 | 'locality_name': 'Newbury', 39 | 'organization_name': 'Codex Non Sufficit LC', 40 | 'common_name': 'Will Bond', 41 | }, 42 | public_key 43 | ) 44 | builder.self_signed = True 45 | certificate = builder.build(private_key) 46 | 47 | with open('/path/to/my/env/will_bond.crt', 'wb') as f: 48 | f.write(pem_armor_certificate(certificate)) 49 | ``` 50 | 51 | All name components must be unicode strings. Common name keys include: 52 | 53 | - `country_name` 54 | - `state_or_province_name` 55 | - `locality_name` 56 | - `organization_name` 57 | - `common_name` 58 | 59 | Less common keys include: 60 | 61 | - `organizational_unit_name` 62 | - `email_address` 63 | - `street_address` 64 | - `postal_code` 65 | - `business_category` 66 | - `incorporation_locality` 67 | - `incorporation_state_or_province` 68 | - `incorporation_country` 69 | 70 | See [`CertificateBuilder.subject`](api.md#subject-attribute) for a full 71 | list of supported name keys. 72 | 73 | ## CA and End-Entity Certificates 74 | 75 | Beyond self-signed certificates lives the world of root CAs, intermediate 76 | CAs and end-entity certificates. 77 | 78 | The example below will create a root CA and then an end-entity certificate 79 | signed by the root. By simply creating another CA certificate signed by the 80 | root CA, an intermediate CA certificate could be added. 81 | 82 | ```python 83 | from oscrypto import asymmetric 84 | from certbuilder import CertificateBuilder, pem_armor_certificate 85 | 86 | 87 | # Generate and save the key and certificate for the root CA 88 | root_ca_public_key, root_ca_private_key = asymmetric.generate_pair('rsa', bit_size=2048) 89 | 90 | with open('/path/to/my/env/root_ca.key', 'wb') as f: 91 | f.write(asymmetric.dump_private_key(root_ca_private_key, 'password')) 92 | 93 | builder = CertificateBuilder( 94 | { 95 | 'country_name': 'US', 96 | 'state_or_province_name': 'Massachusetts', 97 | 'locality_name': 'Newbury', 98 | 'organization_name': 'Codex Non Sufficit LC', 99 | 'common_name': 'CodexNS Root CA 1', 100 | }, 101 | root_ca_public_key 102 | ) 103 | builder.self_signed = True 104 | builder.ca = True 105 | root_ca_certificate = builder.build(root_ca_private_key) 106 | 107 | with open('/path/to/my/env/root_ca.crt', 'wb') as f: 108 | f.write(pem_armor_certificate(root_ca_certificate)) 109 | 110 | 111 | # Generate an end-entity key and certificate, signed by the root 112 | end_entity_public_key, end_entity_private_key = asymmetric.generate_pair('rsa', bit_size=2048) 113 | 114 | with open('/path/to/my/env/will_bond.key', 'wb') as f: 115 | f.write(asymmetric.dump_private_key(end_entity_private_key, 'password')) 116 | 117 | builder = CertificateBuilder( 118 | { 119 | 'country_name': 'US', 120 | 'state_or_province_name': 'Massachusetts', 121 | 'locality_name': 'Newbury', 122 | 'organization_name': 'Codex Non Sufficit LC', 123 | 'common_name': 'Will Bond', 124 | }, 125 | end_entity_public_key 126 | ) 127 | builder.issuer = root_ca_certificate 128 | end_entity_certificate = builder.build(root_ca_private_key) 129 | 130 | with open('/path/to/my/env/will_bond.crt', 'wb') as f: 131 | f.write(pem_armor_certificate(end_entity_certificate)) 132 | ``` 133 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # certbuilder 2 | 3 | A Python library for creating and signing X.509 certificates. 4 | 5 | - [Related Crypto Libraries](#related-crypto-libraries) 6 | - [Current Release](#current-release) 7 | - [Dependencies](#dependencies) 8 | - [Installation](#installation) 9 | - [License](#license) 10 | - [Documentation](#documentation) 11 | - [Continuous Integration](#continuous-integration) 12 | - [Testing](#testing) 13 | - [Development](#development) 14 | - [CI Tasks](#ci-tasks) 15 | 16 | [![GitHub Actions CI](https://github.com/wbond/certbuilder/workflows/CI/badge.svg)](https://github.com/wbond/certbuilder/actions?workflow=CI) 17 | [![CircleCI](https://circleci.com/gh/wbond/certbuilder.svg?style=shield)](https://circleci.com/gh/wbond/certbuilder) 18 | [![PyPI](https://img.shields.io/pypi/v/certbuilder.svg)](https://pypi.python.org/pypi/certbuilder) 19 | 20 | ## Related Crypto Libraries 21 | 22 | *certbuilder* is part of the modularcrypto family of Python packages: 23 | 24 | - [asn1crypto](https://github.com/wbond/asn1crypto) 25 | - [oscrypto](https://github.com/wbond/oscrypto) 26 | - [csrbuilder](https://github.com/wbond/csrbuilder) 27 | - [certbuilder](https://github.com/wbond/certbuilder) 28 | - [crlbuilder](https://github.com/wbond/crlbuilder) 29 | - [ocspbuilder](https://github.com/wbond/ocspbuilder) 30 | - [certvalidator](https://github.com/wbond/certvalidator) 31 | 32 | ## Current Release 33 | 34 | 0.14.2 - [changelog](changelog.md) 35 | 36 | ## Dependencies 37 | 38 | - [*asn1crypto*](https://github.com/wbond/asn1crypto) 39 | - [*oscrypto*](https://github.com/wbond/oscrypto) 40 | - Python 2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9 or pypy 41 | 42 | ## Installation 43 | 44 | ```bash 45 | pip install certbuilder 46 | ``` 47 | 48 | ## License 49 | 50 | *certbuilder* is licensed under the terms of the MIT license. See the 51 | [LICENSE](LICENSE) file for the exact license text. 52 | 53 | ## Documentation 54 | 55 | [*certbuilder* documentation](docs/readme.md) 56 | 57 | ## Continuous Integration 58 | 59 | - [macOS, Linux, Windows](https://github.com/wbond/certbuilder/actions/workflows/ci.yml) via GitHub Actions 60 | - [arm64](https://circleci.com/gh/wbond/certbuilder) via CircleCI 61 | 62 | ## Testing 63 | 64 | Tests are written using `unittest` and require no third-party packages. 65 | 66 | Depending on what type of source is available for the package, the following 67 | commands can be used to run the test suite. 68 | 69 | ### Git Repository 70 | 71 | When working within a Git working copy, or an archive of the Git repository, 72 | the full test suite is run via: 73 | 74 | ```bash 75 | python run.py tests 76 | ``` 77 | 78 | To run only some tests, pass a regular expression as a parameter to `tests`. 79 | 80 | ```bash 81 | python run.py tests build 82 | ``` 83 | 84 | ### PyPi Source Distribution 85 | 86 | When working within an extracted source distribution (aka `.tar.gz`) from 87 | PyPi, the full test suite is run via: 88 | 89 | ```bash 90 | python setup.py test 91 | ``` 92 | 93 | ## Development 94 | 95 | To install the package used for linting, execute: 96 | 97 | ```bash 98 | pip install --user -r requires/lint 99 | ``` 100 | 101 | The following command will run the linter: 102 | 103 | ```bash 104 | python run.py lint 105 | ``` 106 | 107 | Support for code coverage can be installed via: 108 | 109 | ```bash 110 | pip install --user -r requires/coverage 111 | ``` 112 | 113 | Coverage is measured by running: 114 | 115 | ```bash 116 | python run.py coverage 117 | ``` 118 | 119 | To install the packages requires to generate the API documentation, run: 120 | 121 | ```bash 122 | pip install --user -r requires/api_docs 123 | ``` 124 | 125 | The documentation can then be generated by running: 126 | 127 | ```bash 128 | python run.py api_docs 129 | ``` 130 | 131 | To change the version number of the package, run: 132 | 133 | ```bash 134 | python run.py version {pep440_version} 135 | ``` 136 | 137 | To install the necessary packages for releasing a new version on PyPI, run: 138 | 139 | ```bash 140 | pip install --user -r requires/release 141 | ``` 142 | 143 | Releases are created by: 144 | 145 | - Making a git tag in [PEP 440](https://www.python.org/dev/peps/pep-0440/#examples-of-compliant-version-schemes) format 146 | - Running the command: 147 | 148 | ```bash 149 | python run.py release 150 | ``` 151 | 152 | Existing releases can be found at https://pypi.org/project/certbuilder. 153 | 154 | ## CI Tasks 155 | 156 | A task named `deps` exists to ensure a modern version of `pip` is installed, 157 | along with all necessary testing dependencies. 158 | 159 | The `ci` task runs `lint` (if flake8 is avaiable for the version of Python) and 160 | `coverage` (or `tests` if coverage is not available for the version of Python). 161 | If the current directory is a clean git working copy, the coverage data is 162 | submitted to codecov.io. 163 | 164 | ```bash 165 | python run.py deps 166 | python run.py ci 167 | ``` 168 | -------------------------------------------------------------------------------- /requires/api_docs: -------------------------------------------------------------------------------- 1 | CommonMark >= 0.6.0 2 | -------------------------------------------------------------------------------- /requires/ci: -------------------------------------------------------------------------------- 1 | setuptools == 36.8.0 ; python_version == '2.6' 2 | setuptools == 44.1.1 ; python_version == '2.7' and sys_platform == 'win32' 3 | setuptools == 18.4 ; python_version == '3.2' 4 | setuptools == 39.2.0 ; python_version == '3.3' 5 | https://github.com/wbond/asn1crypto/archive/master.zip 6 | https://github.com/wbond/oscrypto/archive/master.zip 7 | https://github.com/wbond/oscrypto/archive/master.zip#egg=oscrypto_tests&subdirectory=tests 8 | -r ./coverage 9 | -r ./lint 10 | # cffi 3.15.0 is required for Python 3.10 11 | cffi == 1.15.0 ; (python_version == '2.7' or python_version >= '3.6') and sys_platform == 'darwin' 12 | pycparser == 2.19 ; (python_version == '2.7' or python_version >= '3.6') and sys_platform == 'darwin' 13 | -------------------------------------------------------------------------------- /requires/coverage: -------------------------------------------------------------------------------- 1 | coverage == 4.4.1 ; python_version == '2.6' 2 | coverage == 4.5.4 ; python_version == '3.3' or python_version == '3.4' 3 | coverage == 5.5 ; python_version == '2.7' or python_version == '3.5' or python_version == '3.6' 4 | coverage == 6.5.0 ; python_version == '3.7' 5 | coverage == 7.3.0 ; python_version >= '3.8' 6 | -------------------------------------------------------------------------------- /requires/lint: -------------------------------------------------------------------------------- 1 | setuptools >= 39.0.1 ; python_version == '2.7' or python_version >= '3.3' 2 | enum34 == 1.1.6 ; python_version == '2.7' or python_version == '3.3' 3 | configparser == 3.5.0 ; python_version == '2.7' 4 | mccabe == 0.6.1 ; python_version == '3.3' 5 | pycodestyle == 2.3.1 ; python_version == '3.3' 6 | pyflakes == 1.6.0 ; python_version == '3.3' 7 | flake8 == 3.5.0 ; python_version == '3.3' 8 | mccabe == 0.6.1 ; python_version == '2.7' or python_version >= '3.4' 9 | pycodestyle == 2.5.0 ; python_version == '2.7' or python_version >= '3.4' 10 | pyflakes == 2.1.1 ; python_version == '2.7' or python_version >= '3.4' 11 | functools32 == 3.2.3-2 ; python_version == '2.7' 12 | typing == 3.7.4.1 ; python_version == '2.7' or python_version == '3.4' 13 | entrypoints == 0.3 ; python_version == '2.7' or python_version >= '3.4' 14 | flake8 == 3.7.9 ; python_version == '2.7' or python_version >= '3.4' 15 | -------------------------------------------------------------------------------- /requires/release: -------------------------------------------------------------------------------- 1 | wheel >= 0.31.0 2 | twine >= 1.11.0 3 | setuptools >= 38.6.0 4 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | from __future__ import unicode_literals, division, absolute_import, print_function 4 | 5 | from dev._task import run_task 6 | 7 | 8 | run_task() 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import shutil 4 | import sys 5 | import warnings 6 | 7 | import setuptools 8 | from setuptools import find_packages, setup, Command 9 | from setuptools.command.egg_info import egg_info 10 | 11 | 12 | PACKAGE_NAME = 'certbuilder' 13 | PACKAGE_VERSION = '0.14.2' 14 | PACKAGE_ROOT = os.path.dirname(os.path.abspath(__file__)) 15 | 16 | 17 | # setuptools 38.6.0 and newer know about long_description_content_type, but 18 | # distutils still complains about it, so silence the warning 19 | sv = setuptools.__version__ 20 | svi = tuple(int(o) if o.isdigit() else o for o in sv.split('.')) 21 | if svi >= (38, 6): 22 | warnings.filterwarnings( 23 | 'ignore', 24 | "Unknown distribution option: 'long_description_content_type'", 25 | module='distutils.dist' 26 | ) 27 | 28 | 29 | # This allows us to send the LICENSE and docs when creating a sdist. Wheels 30 | # automatically include the LICENSE, and don't need the docs. For these 31 | # to be included, the command must be "python setup.py sdist". 32 | package_data = {} 33 | if sys.argv[1:] == ['sdist'] or sorted(sys.argv[1:]) == ['-q', 'sdist']: 34 | package_data[PACKAGE_NAME] = [ 35 | '../LICENSE', 36 | '../*.md', 37 | '../docs/*.md', 38 | ] 39 | 40 | 41 | # Ensures a copy of the LICENSE is included with the egg-info for 42 | # install and bdist_egg commands 43 | class EggInfoCommand(egg_info): 44 | def run(self): 45 | egg_info_path = os.path.join( 46 | PACKAGE_ROOT, 47 | '%s.egg-info' % PACKAGE_NAME 48 | ) 49 | if not os.path.exists(egg_info_path): 50 | os.mkdir(egg_info_path) 51 | shutil.copy2( 52 | os.path.join(PACKAGE_ROOT, 'LICENSE'), 53 | os.path.join(egg_info_path, 'LICENSE') 54 | ) 55 | egg_info.run(self) 56 | 57 | 58 | class CleanCommand(Command): 59 | user_options = [ 60 | ('all', 'a', '(Compatibility with original clean command)'), 61 | ] 62 | 63 | def initialize_options(self): 64 | self.all = False 65 | 66 | def finalize_options(self): 67 | pass 68 | 69 | def run(self): 70 | sub_folders = ['build', 'temp', '%s.egg-info' % PACKAGE_NAME] 71 | if self.all: 72 | sub_folders.append('dist') 73 | for sub_folder in sub_folders: 74 | full_path = os.path.join(PACKAGE_ROOT, sub_folder) 75 | if os.path.exists(full_path): 76 | shutil.rmtree(full_path) 77 | for root, dirs, files in os.walk(os.path.join(PACKAGE_ROOT, PACKAGE_NAME)): 78 | for filename in files: 79 | if filename[-4:] == '.pyc': 80 | os.unlink(os.path.join(root, filename)) 81 | for dirname in list(dirs): 82 | if dirname == '__pycache__': 83 | shutil.rmtree(os.path.join(root, dirname)) 84 | 85 | 86 | readme = '' 87 | with codecs.open(os.path.join(PACKAGE_ROOT, 'readme.md'), 'r', 'utf-8') as f: 88 | readme = f.read() 89 | 90 | 91 | setup( 92 | name=PACKAGE_NAME, 93 | version=PACKAGE_VERSION, 94 | 95 | description='Creates and signs X.509 certificates', 96 | long_description=readme, 97 | long_description_content_type='text/markdown', 98 | 99 | url='https://github.com/wbond/certbuilder', 100 | 101 | author='wbond', 102 | author_email='will@wbond.net', 103 | 104 | license='MIT', 105 | 106 | classifiers=[ 107 | 'Development Status :: 4 - Beta', 108 | 109 | 'Intended Audience :: Developers', 110 | 111 | 'License :: OSI Approved :: MIT License', 112 | 113 | 'Programming Language :: Python :: 2', 114 | 'Programming Language :: Python :: 2.6', 115 | 'Programming Language :: Python :: 2.7', 116 | 'Programming Language :: Python :: 3', 117 | 'Programming Language :: Python :: 3.2', 118 | 'Programming Language :: Python :: 3.3', 119 | 'Programming Language :: Python :: 3.4', 120 | 'Programming Language :: Python :: 3.5', 121 | 'Programming Language :: Python :: 3.6', 122 | 'Programming Language :: Python :: 3.7', 123 | 'Programming Language :: Python :: 3.8', 124 | 'Programming Language :: Python :: 3.9', 125 | 'Programming Language :: Python :: Implementation :: PyPy', 126 | 127 | 'Topic :: Security :: Cryptography', 128 | ], 129 | 130 | keywords='crypto pki x509 certificate rsa dsa ec', 131 | 132 | install_requires=[ 133 | 'asn1crypto>=1.2.0', 134 | 'oscrypto>=1.1.0' 135 | ], 136 | packages=[PACKAGE_NAME], 137 | package_data=package_data, 138 | 139 | test_suite='tests.make_suite', 140 | 141 | cmdclass={ 142 | 'clean': CleanCommand, 143 | 'egg_info': EggInfoCommand, 144 | } 145 | ) 146 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | import os 5 | import sys 6 | import unittest 7 | 8 | if sys.version_info < (3, 5): 9 | import imp 10 | else: 11 | import importlib 12 | import importlib.abc 13 | import importlib.util 14 | 15 | 16 | if sys.version_info >= (3, 5): 17 | class ModCryptoMetaFinder(importlib.abc.MetaPathFinder): 18 | def setup(self): 19 | self.modules = {} 20 | sys.meta_path.insert(0, self) 21 | 22 | def add_module(self, package_name, package_path): 23 | if package_name not in self.modules: 24 | self.modules[package_name] = package_path 25 | 26 | def find_spec(self, fullname, path, target=None): 27 | name_parts = fullname.split('.') 28 | if name_parts[0] not in self.modules: 29 | return None 30 | 31 | package = name_parts[0] 32 | package_path = self.modules[package] 33 | 34 | fullpath = os.path.join(package_path, *name_parts[1:]) 35 | 36 | if os.path.isdir(fullpath): 37 | filename = os.path.join(fullpath, "__init__.py") 38 | submodule_locations = [fullpath] 39 | else: 40 | filename = fullpath + ".py" 41 | submodule_locations = None 42 | 43 | if not os.path.exists(filename): 44 | return None 45 | 46 | return importlib.util.spec_from_file_location( 47 | fullname, 48 | filename, 49 | loader=None, 50 | submodule_search_locations=submodule_locations 51 | ) 52 | 53 | CUSTOM_FINDER = ModCryptoMetaFinder() 54 | CUSTOM_FINDER.setup() 55 | 56 | 57 | def _import_from(mod, path, mod_dir=None): 58 | """ 59 | Imports a module from a specific path 60 | 61 | :param mod: 62 | A unicode string of the module name 63 | 64 | :param path: 65 | A unicode string to the directory containing the module 66 | 67 | :param mod_dir: 68 | If the sub directory of "path" is different than the "mod" name, 69 | pass the sub directory as a unicode string 70 | 71 | :return: 72 | None if not loaded, otherwise the module 73 | """ 74 | 75 | if mod in sys.modules: 76 | return sys.modules[mod] 77 | 78 | if mod_dir is None: 79 | full_mod = mod 80 | else: 81 | full_mod = mod_dir.replace(os.sep, '.') 82 | 83 | if mod_dir is None: 84 | mod_dir = mod.replace('.', os.sep) 85 | 86 | if not os.path.exists(path): 87 | return None 88 | 89 | source_path = os.path.join(path, mod_dir, '__init__.py') 90 | if not os.path.exists(source_path): 91 | source_path = os.path.join(path, mod_dir + '.py') 92 | 93 | if not os.path.exists(source_path): 94 | return None 95 | 96 | if os.sep in mod_dir: 97 | append, mod_dir = mod_dir.rsplit(os.sep, 1) 98 | path = os.path.join(path, append) 99 | 100 | try: 101 | if sys.version_info < (3, 5): 102 | mod_info = imp.find_module(mod_dir, [path]) 103 | return imp.load_module(mod, *mod_info) 104 | 105 | else: 106 | package = mod.split('.', 1)[0] 107 | package_dir = full_mod.split('.', 1)[0] 108 | package_path = os.path.join(path, package_dir) 109 | CUSTOM_FINDER.add_module(package, package_path) 110 | 111 | return importlib.import_module(mod) 112 | 113 | except ImportError: 114 | return None 115 | 116 | 117 | def make_suite(): 118 | """ 119 | Constructs a unittest.TestSuite() of all tests for the package. For use 120 | with setuptools. 121 | 122 | :return: 123 | A unittest.TestSuite() object 124 | """ 125 | 126 | loader = unittest.TestLoader() 127 | suite = unittest.TestSuite() 128 | for test_class in test_classes(): 129 | tests = loader.loadTestsFromTestCase(test_class) 130 | suite.addTests(tests) 131 | return suite 132 | 133 | 134 | def test_classes(): 135 | """ 136 | Returns a list of unittest.TestCase classes for the package 137 | 138 | :return: 139 | A list of unittest.TestCase classes 140 | """ 141 | 142 | # Make sure the module is loaded from this source folder 143 | tests_dir = os.path.dirname(os.path.abspath(__file__)) 144 | 145 | _import_from( 146 | 'certbuilder', 147 | os.path.join(tests_dir, '..') 148 | ) 149 | 150 | from .test_certificate_builder import CertificateBuilderTests 151 | 152 | return [CertificateBuilderTests] 153 | -------------------------------------------------------------------------------- /tests/test_certificate_builder.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import unicode_literals, division, absolute_import, print_function 3 | 4 | from datetime import datetime 5 | import unittest 6 | import os 7 | 8 | import asn1crypto.x509 9 | from asn1crypto.util import timezone 10 | from oscrypto import asymmetric 11 | from certbuilder import CertificateBuilder 12 | 13 | 14 | tests_root = os.path.dirname(__file__) 15 | fixtures_dir = os.path.join(tests_root, 'fixtures') 16 | 17 | 18 | class lazy_class_property(object): # noqa 19 | """ 20 | Used for caching lazily generated key pairs. 21 | """ 22 | 23 | def __init__(self, getter): 24 | self.getter = getter 25 | 26 | def __get__(self, instance, owner): 27 | value = self.getter(owner) 28 | setattr(owner, self.getter.__name__, value) 29 | 30 | return value 31 | 32 | 33 | class CertificateBuilderTests(unittest.TestCase): 34 | def test_subject_alt_name_shortcuts(self): 35 | public_key, private_key = self.ec_secp256r1 36 | 37 | builder = CertificateBuilder( 38 | {'country_name': 'US', 'common_name': 'Test'}, 39 | public_key 40 | ) 41 | builder.self_signed = True 42 | 43 | self.assertEqual(builder.subject_alt_domains, []) 44 | 45 | builder.subject_alt_domains = ['example.com', 'example.org'] 46 | builder.subject_alt_emails = ['test@example.com', 'test2@example.com'] 47 | builder.subject_alt_ips = ['127.0.0.1'] 48 | builder.subject_alt_uris = ['http://example.com', 'https://bücher.ch'] 49 | 50 | self.assertEqual(builder.subject_alt_domains, ['example.com', 'example.org']) 51 | self.assertEqual(builder.subject_alt_emails, ['test@example.com', 'test2@example.com']) 52 | self.assertEqual(builder.subject_alt_ips, ['127.0.0.1']) 53 | self.assertEqual(builder.subject_alt_uris, ['http://example.com', 'https://bücher.ch']) 54 | 55 | builder.subject_alt_domains = [] 56 | self.assertEqual(builder.subject_alt_domains, []) 57 | 58 | builder.subject_alt_emails = [] 59 | self.assertEqual(builder.subject_alt_emails, []) 60 | 61 | builder.subject_alt_ips = [] 62 | self.assertEqual(builder.subject_alt_ips, []) 63 | 64 | builder.subject_alt_uris = [] 65 | self.assertEqual(builder.subject_alt_uris, []) 66 | 67 | builder.subject_alt_uris = ['https://bücher.ch'] 68 | 69 | certificate = builder.build(private_key) 70 | 71 | self.assertEqual(b'https://xn--bcher-kva.ch', certificate.subject_alt_name_value[0].chosen.contents) 72 | 73 | def test_build_end_entity_cert(self): 74 | public_key, private_key = self.ec_secp256r1 75 | 76 | builder = CertificateBuilder( 77 | { 78 | 'country_name': 'US', 79 | 'state_or_province_name': 'Massachusetts', 80 | 'locality_name': 'Newbury', 81 | 'organization_name': 'Codex Non Sufficit LC', 82 | 'common_name': 'Will Bond', 83 | }, 84 | public_key 85 | ) 86 | builder.self_signed = True 87 | builder.subject_alt_domains = ['example.com'] 88 | certificate = builder.build(private_key) 89 | der_bytes = certificate.dump() 90 | 91 | new_certificate = asn1crypto.x509.Certificate.load(der_bytes) 92 | 93 | self.assertEqual('sha256', new_certificate.hash_algo) 94 | self.assertEqual( 95 | { 96 | 'country_name': 'US', 97 | 'state_or_province_name': 'Massachusetts', 98 | 'locality_name': 'Newbury', 99 | 'organization_name': 'Codex Non Sufficit LC', 100 | 'common_name': 'Will Bond', 101 | }, 102 | new_certificate.issuer.native 103 | ) 104 | self.assertEqual( 105 | { 106 | 'country_name': 'US', 107 | 'state_or_province_name': 'Massachusetts', 108 | 'locality_name': 'Newbury', 109 | 'organization_name': 'Codex Non Sufficit LC', 110 | 'common_name': 'Will Bond', 111 | }, 112 | new_certificate.subject.native 113 | ) 114 | self.assertEqual('ecdsa', new_certificate.signature_algo) 115 | self.assertEqual(set(['key_usage']), new_certificate.critical_extensions) 116 | self.assertEqual(set(['digital_signature', 'key_encipherment']), new_certificate.key_usage_value.native) 117 | self.assertEqual(['server_auth', 'client_auth'], new_certificate.extended_key_usage_value.native) 118 | self.assertEqual(None, new_certificate.authority_key_identifier) 119 | self.assertEqual(False, new_certificate.ca) 120 | self.assertEqual(True, new_certificate.self_issued) 121 | self.assertEqual('maybe', new_certificate.self_signed) 122 | self.assertEqual(certificate.public_key.sha1, new_certificate.key_identifier) 123 | self.assertEqual(['example.com'], new_certificate.valid_domains) 124 | 125 | def test_build_ca_cert(self): 126 | public_key, private_key = self.ec_secp256r1 127 | 128 | builder = CertificateBuilder( 129 | { 130 | 'country_name': 'US', 131 | 'state_or_province_name': 'Massachusetts', 132 | 'locality_name': 'Newbury', 133 | 'organization_name': 'Codex Non Sufficit LC', 134 | 'common_name': 'Will Bond', 135 | }, 136 | public_key 137 | ) 138 | builder.hash_algo = 'sha512' 139 | builder.self_signed = True 140 | builder.ca = True 141 | certificate = builder.build(private_key) 142 | der_bytes = certificate.dump() 143 | 144 | new_certificate = asn1crypto.x509.Certificate.load(der_bytes) 145 | 146 | self.assertEqual('sha512', new_certificate.hash_algo) 147 | self.assertEqual( 148 | { 149 | 'country_name': 'US', 150 | 'state_or_province_name': 'Massachusetts', 151 | 'locality_name': 'Newbury', 152 | 'organization_name': 'Codex Non Sufficit LC', 153 | 'common_name': 'Will Bond', 154 | }, 155 | new_certificate.issuer.native 156 | ) 157 | self.assertEqual( 158 | { 159 | 'country_name': 'US', 160 | 'state_or_province_name': 'Massachusetts', 161 | 'locality_name': 'Newbury', 162 | 'organization_name': 'Codex Non Sufficit LC', 163 | 'common_name': 'Will Bond', 164 | }, 165 | new_certificate.subject.native 166 | ) 167 | self.assertEqual('ecdsa', new_certificate.signature_algo) 168 | self.assertEqual(set(['key_usage', 'basic_constraints']), new_certificate.critical_extensions) 169 | self.assertEqual(set(['key_cert_sign', 'crl_sign']), new_certificate.key_usage_value.native) 170 | self.assertEqual(None, new_certificate.extended_key_usage_value) 171 | self.assertEqual(None, new_certificate.authority_key_identifier) 172 | self.assertEqual(True, new_certificate.ca) 173 | self.assertEqual(True, new_certificate.self_issued) 174 | self.assertEqual('maybe', new_certificate.self_signed) 175 | self.assertEqual(certificate.public_key.sha1, new_certificate.key_identifier) 176 | 177 | def test_build_chain_of_certs(self): 178 | ca_public_key, ca_private_key = self.ec_secp521r1 179 | ee_public_key, _ = self.ec_secp256r1 180 | 181 | ca_builder = CertificateBuilder( 182 | { 183 | 'country_name': 'US', 184 | 'state_or_province_name': 'Massachusetts', 185 | 'locality_name': 'Newbury', 186 | 'organization_name': 'Codex Non Sufficit LC', 187 | 'common_name': 'Codex Non Sufficit LC - Primary CA', 188 | }, 189 | ca_public_key 190 | ) 191 | ca_builder.hash_algo = 'sha512' 192 | ca_builder.self_signed = True 193 | ca_builder.ca = True 194 | ca_certificate = ca_builder.build(ca_private_key) 195 | 196 | ee_builder = CertificateBuilder( 197 | { 198 | 'country_name': 'US', 199 | 'state_or_province_name': 'Massachusetts', 200 | 'locality_name': 'Newbury', 201 | 'organization_name': 'Codex Non Sufficit LC', 202 | 'common_name': 'Will Bond', 203 | }, 204 | ee_public_key 205 | ) 206 | ee_builder.issuer = ca_certificate 207 | ee_builder.serial_number = 1 208 | ee_certificate = ee_builder.build(ca_private_key) 209 | der_bytes = ee_certificate.dump() 210 | 211 | new_certificate = asn1crypto.x509.Certificate.load(der_bytes) 212 | 213 | self.assertEqual('sha256', new_certificate.hash_algo) 214 | self.assertEqual( 215 | { 216 | 'country_name': 'US', 217 | 'state_or_province_name': 'Massachusetts', 218 | 'locality_name': 'Newbury', 219 | 'organization_name': 'Codex Non Sufficit LC', 220 | 'common_name': 'Codex Non Sufficit LC - Primary CA', 221 | }, 222 | new_certificate.issuer.native 223 | ) 224 | self.assertEqual( 225 | { 226 | 'country_name': 'US', 227 | 'state_or_province_name': 'Massachusetts', 228 | 'locality_name': 'Newbury', 229 | 'organization_name': 'Codex Non Sufficit LC', 230 | 'common_name': 'Will Bond', 231 | }, 232 | new_certificate.subject.native 233 | ) 234 | self.assertEqual('ecdsa', new_certificate.signature_algo) 235 | self.assertEqual(set(['key_usage']), new_certificate.critical_extensions) 236 | self.assertEqual(set(['digital_signature', 'key_encipherment']), new_certificate.key_usage_value.native) 237 | self.assertEqual(['server_auth', 'client_auth'], new_certificate.extended_key_usage_value.native) 238 | self.assertEqual(ca_certificate.key_identifier, new_certificate.authority_key_identifier) 239 | self.assertEqual(False, new_certificate.ca) 240 | self.assertEqual(False, new_certificate.self_issued) 241 | self.assertEqual('no', new_certificate.self_signed) 242 | 243 | def test_validity_utc_times(self): 244 | public_key, private_key = self.ec_secp256r1 245 | 246 | builder = CertificateBuilder( 247 | { 248 | 'country_name': 'US', 249 | 'state_or_province_name': 'Massachusetts', 250 | 'locality_name': 'Newbury', 251 | 'organization_name': 'Codex Non Sufficit LC', 252 | 'common_name': 'Will Bond', 253 | }, 254 | public_key 255 | ) 256 | builder.self_signed = True 257 | builder.begin_date = datetime(2045, 1, 1, tzinfo=timezone.utc) 258 | builder.end_date = datetime(2049, 12, 31, tzinfo=timezone.utc) 259 | certificate = builder.build(private_key) 260 | der_bytes = certificate.dump() 261 | 262 | new_certificate = asn1crypto.x509.Certificate.load(der_bytes) 263 | 264 | self.assertEqual(new_certificate['tbs_certificate']['validity']['not_before'].name, 'utc_time') 265 | self.assertEqual(new_certificate['tbs_certificate']['validity']['not_after'].name, 'utc_time') 266 | 267 | def test_validity_general_times(self): 268 | public_key, private_key = self.ec_secp256r1 269 | 270 | builder = CertificateBuilder( 271 | { 272 | 'country_name': 'US', 273 | 'state_or_province_name': 'Massachusetts', 274 | 'locality_name': 'Newbury', 275 | 'organization_name': 'Codex Non Sufficit LC', 276 | 'common_name': 'Will Bond', 277 | }, 278 | public_key 279 | ) 280 | builder.self_signed = True 281 | builder.begin_date = datetime(2050, 1, 1, tzinfo=timezone.utc) 282 | builder.end_date = datetime(2052, 1, 1, tzinfo=timezone.utc) 283 | certificate = builder.build(private_key) 284 | der_bytes = certificate.dump() 285 | 286 | new_certificate = asn1crypto.x509.Certificate.load(der_bytes) 287 | 288 | self.assertEqual(new_certificate['tbs_certificate']['validity']['not_before'].name, 'general_time') 289 | self.assertEqual(new_certificate['tbs_certificate']['validity']['not_after'].name, 'general_time') 290 | 291 | # Cached key pairs 292 | @lazy_class_property 293 | def ec_secp256r1(self): 294 | return asymmetric.generate_pair('ec', curve='secp256r1') 295 | 296 | @lazy_class_property 297 | def ec_secp521r1(self): 298 | return asymmetric.generate_pair('ec', curve='secp521r1') 299 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26,py27,py32,py33,py34,py35,py36,py37,py38,py39,pypy 3 | 4 | [testenv] 5 | deps = -rrequires/ci 6 | commands = {envpython} run.py ci 7 | 8 | [pep8] 9 | max-line-length = 120 10 | 11 | [flake8] 12 | max-line-length = 120 13 | jobs = 1 14 | --------------------------------------------------------------------------------