├── .gitignore ├── LICENSE ├── README.md ├── contributor_network ├── __init__.py ├── cli.py ├── graph.py ├── template │ ├── index.html │ └── static │ │ └── js │ │ ├── jquery.min.js │ │ └── vis-network.min.js └── utils.py ├── data ├── contributor-network-graph.png └── dependencies.csv ├── parse_requirements.py ├── requirements-development.txt ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.csv 2 | *.db 3 | *.egg-info/ 4 | *.pyc 5 | *.sqlite 6 | *.sw? 7 | *~ 8 | .*.sw? 9 | .DS_Store 10 | .activate 11 | .coverage 12 | .directory 13 | .env 14 | .idea/* 15 | .ipynb_checkpoints/ 16 | .pytest_cache/ 17 | .tox 18 | MANIFEST 19 | build/* 20 | data/ 21 | dist/* 22 | docs-build/ 23 | docs/man/ 24 | docs/reference/ 25 | reg_settings.py 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contributor Network Graph 2 | 3 | Create a graph visualization from a code repository. 4 | 5 | ![Graph for contributor-network repository](data/contributor-network-graph.png) 6 | 7 | 8 | ## Installing 9 | 10 | ``` 11 | pip install contributor-network 12 | ``` 13 | 14 | ## Running 15 | 16 | Given a file called `dependencies.csv`, like: 17 | 18 | ```csv 19 | name,repository_type,repository_url,depended_by 20 | contributor-network,git,https://github.com/PythonicCafe/contributor-network, 21 | lxml,git,https://github.com/lxml/lxml,contributor-network 22 | tqdm,git,https://github.com/tqdm/tqdm,contributor-network 23 | ``` 24 | 25 | Execute: 26 | 27 | ```shell 28 | python -m contributor_network.cli \ 29 | --temp-dir=/tmp/repositories/ \ 30 | dependencies.csv \ 31 | network/ 32 | ``` 33 | -------------------------------------------------------------------------------- /contributor_network/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 1, 1) 2 | -------------------------------------------------------------------------------- /contributor_network/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import shutil 3 | import tempfile 4 | from pathlib import Path 5 | 6 | from tqdm import tqdm 7 | 8 | from .graph import Graph, Package, Contributor 9 | from .utils import read_csv 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("input_filename") 15 | parser.add_argument("output_path") 16 | parser.add_argument("--template-dir", default=str(Path(__file__).parent / "template")) 17 | parser.add_argument("--temp-dir", default=str(Path(tempfile.gettempdir()) / "repositories")) 18 | args = parser.parse_args() 19 | 20 | input_filename = args.input_filename 21 | # TODO: check if input_filename has needed columns 22 | 23 | output_path = Path(args.output_path) 24 | output_filename = output_path / "data" / "network.json" 25 | image_path = output_path / "static" / "img" 26 | 27 | templates_path = Path(args.template_dir) 28 | shutil.copytree(templates_path, output_path) 29 | 30 | temp_path = Path(args.temp_dir) 31 | 32 | graph = Graph() 33 | 34 | # First, add nodes and save contributors 35 | iterator = tqdm(read_csv(input_filename)) 36 | for package_data in iterator: 37 | iterator.desc = f"Extracting repository data: {package_data['name']}" 38 | iterator.refresh() 39 | package_data.pop("depended_by") 40 | package_data["repository_path"] = temp_path / package_data["name"] 41 | package = Package(**package_data) 42 | graph.add_node(package.serialize()) 43 | 44 | filename = output_path / "data" / f"{package.id}.json" 45 | if not filename.exists(): 46 | package.save_contributors(filename, output_path, image_path) 47 | 48 | # Then, add edges 49 | iterator = tqdm(read_csv(input_filename)) 50 | for package_data in iterator: 51 | iterator.desc = f"Adding dependencies: {package_data['name']}" 52 | iterator.refresh() 53 | depended_by = package_data.pop("depended_by") 54 | if depended_by: 55 | graph.add_edge( 56 | from_id=graph.get_node_by_name(depended_by)["id"], 57 | to_id=graph.get_node_by_name(package_data["name"])["id"], 58 | label="depends on", 59 | width=10, 60 | color="blue", 61 | ) 62 | 63 | print("Exporting network JSON...", end="", flush=True) 64 | graph.save(output_filename) 65 | print() 66 | 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /contributor_network/graph.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import shlex 4 | import subprocess 5 | import tempfile 6 | from collections import Counter 7 | from dataclasses import dataclass 8 | from email.utils import getaddresses 9 | from pathlib import Path 10 | from urllib.request import urlopen 11 | 12 | from .utils import transform 13 | 14 | 15 | BASE_PATH = Path(__file__).parent 16 | DATA_PATH = BASE_PATH / "data" 17 | IMAGE_PATH = DATA_PATH / "img" 18 | 19 | 20 | def list_contributors(repository_type, repository_url, path): 21 | if repository_type not in ("git", "hg"): 22 | raise NotImplementedError(f"Unknown repository type: {repr(repository_type)}") 23 | 24 | save_path = (Path(path) / Path(repository_url).name).absolute() 25 | if not save_path.parent.exists(): 26 | save_path.parent.mkdir(parents=True) 27 | 28 | if not save_path.exists(): # clone repository 29 | command = f'{repository_type} clone "{repository_url}" "{save_path}"' 30 | subprocess.check_output(shlex.split(command), stderr=subprocess.PIPE) 31 | 32 | lines = subprocess.check_output( 33 | shlex.split(f"{repository_type} log"), cwd=save_path, encoding="utf-8", 34 | ).splitlines() 35 | c = Counter() 36 | if repository_type == "git": 37 | author_key = "Author:" 38 | elif repository_type == "hg": 39 | author_key = "user:" 40 | 41 | names = {} 42 | for line in lines: 43 | if not line.startswith(author_key) or "@" not in line: 44 | continue 45 | name, email = getaddresses([line[len(author_key) :].strip()])[0] 46 | email = email.strip().lower() 47 | names[email] = name 48 | c[email] += 1 49 | for email, commits in c.most_common(): 50 | yield (names.get(email, email), email, commits) 51 | 52 | 53 | @dataclass 54 | class Package: 55 | name: str 56 | repository_type: str 57 | repository_url: str 58 | repository_path: str 59 | 60 | @property 61 | def id(self): 62 | return f"package:{self.name}" 63 | 64 | def serialize(self): 65 | return { 66 | "group": "package", 67 | "id": self.id, 68 | "label": self.name, 69 | "name": self.name, 70 | "shape": "circle", 71 | } 72 | 73 | def contributors(self): 74 | if not hasattr(self, "_contributors"): 75 | iterator = list_contributors( 76 | self.repository_type, self.repository_url, self.repository_path 77 | ) 78 | self._contributors = [ 79 | Contributor(name=name, email=email, commits=commits, package=self) 80 | for name, email, commits in iterator 81 | ] 82 | yield from self._contributors 83 | 84 | @property 85 | def min_commits(self): 86 | if not hasattr(self, "_min_commits"): 87 | self._min_commits = min(contributor.commits for contributor in self.contributors()) 88 | return self._min_commits 89 | 90 | @property 91 | def max_commits(self): 92 | if not hasattr(self, "_max_commits"): 93 | self._max_commits = max(contributor.commits for contributor in self.contributors()) 94 | return self._max_commits 95 | 96 | @property 97 | def commits(self): 98 | if not hasattr(self, "_commits"): 99 | self._commits = sum(contributor.commits for contributor in self.contributors()) 100 | return self._commits 101 | 102 | def save_contributors(self, filename, base_path, image_path): 103 | image_path = Path(image_path) 104 | graph = Graph() 105 | for contributor in self.contributors(): 106 | node = contributor.serialize() 107 | if "image" in node and ( 108 | node.get("image", "").startswith("http://") or 109 | node.get("image", "").startswith("https://") 110 | ): 111 | image_filename = image_path / str(node["id"]) 112 | if not image_filename.exists(): 113 | if not image_filename.parent.exists(): 114 | image_filename.parent.mkdir(parents=True) 115 | response = urlopen(node["image"]) 116 | node["image"] = str(image_filename.relative_to(base_path)) 117 | with open(image_filename, mode="wb") as fobj: 118 | fobj.write(response.read()) 119 | graph.add_node(node) 120 | graph.add_edge( 121 | from_id=contributor.id, 122 | to_id=contributor.package.id, 123 | label="contributed to", 124 | width=contributor.commit_weight, 125 | ) 126 | 127 | graph.save(filename) 128 | 129 | 130 | @dataclass 131 | class Contributor: 132 | name: str 133 | email: str 134 | commits: int 135 | package: Package 136 | 137 | @property 138 | def email_hash(self): 139 | return hashlib.md5(self.email.strip().lower().encode("utf-8")).hexdigest() 140 | 141 | @property 142 | def id(self): 143 | return f"person:{self.email_hash}" 144 | 145 | @property 146 | def avatar_url(self): 147 | return f"https://www.gravatar.com/avatar/{self.email_hash}" 148 | 149 | @property 150 | def commit_weight(self): 151 | return transform(self.commits, self.package.min_commits, self.package.max_commits, 1, 10) 152 | 153 | def serialize(self): 154 | return { 155 | "group": "person", 156 | "id": self.id, 157 | "image": self.avatar_url, 158 | "label": self.name, 159 | "shape": "circularImage", 160 | } 161 | 162 | 163 | class Graph: 164 | 165 | def __init__(self): 166 | self.__nodes_by_id = {} 167 | self.__nodes_by_name = {} 168 | self.__edges = [] 169 | 170 | def add_node(self, node): 171 | self.__nodes_by_id[node["id"]] = node 172 | if "name" in node: 173 | self.__nodes_by_name[node["name"]] = node 174 | 175 | def get_node_by_name(self, name): 176 | return self.__nodes_by_name[name] 177 | 178 | def add_edge(self, from_id, to_id, label, width=1, color="black"): 179 | self.__edges.append( 180 | { 181 | "from": from_id, 182 | "label": label, 183 | "to": to_id, 184 | "width": width, 185 | "color": color, 186 | } 187 | ) 188 | 189 | def save(self, filename): 190 | filename = Path(filename) 191 | if not filename.parent.exists(): 192 | filename.parent.mkdir(parents=True) 193 | with open(filename, mode="w") as fobj: 194 | json.dump( 195 | { 196 | "nodes": list(self.__nodes_by_id.values()), 197 | "edges": self.__edges, 198 | }, 199 | fobj, 200 | ) 201 | -------------------------------------------------------------------------------- /contributor_network/template/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Contributors 5 | 6 | 17 | 18 | 19 | 20 | 21 | 76 | 77 | 78 | 79 |
80 | 81 | 82 | -------------------------------------------------------------------------------- /contributor_network/template/static/js/jquery.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ 2 | !function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 max_input: 40 | raise ValueError(f"Max allowed value: {max_input} (got: {x})") 41 | elif x == min_input: 42 | return min_output 43 | elif x == max_input: 44 | return max_output 45 | else: 46 | return (x - min_input) * (max_output - min_output) / (max_input - min_input) + min_output 47 | -------------------------------------------------------------------------------- /data/contributor-network-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PythonicCafe/contributor-network/ef88766c8fb69b6a103bf2d8f7aa7ba61542d606/data/contributor-network-graph.png -------------------------------------------------------------------------------- /data/dependencies.csv: -------------------------------------------------------------------------------- 1 | name,repository_type,repository_url,depended_by 2 | contributor-network,git,/home/turicas/projects/contributor-network/.git, 3 | lxml,git,https://github.com/lxml/lxml,contributor-network 4 | tqdm,git,https://github.com/tqdm/tqdm,contributor-network 5 | -------------------------------------------------------------------------------- /parse_requirements.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | REGEXP_PACKAGE_NAME_VERSION = re.compile("[ ><=,]") 5 | 6 | 7 | # TODO: search inside setup.py files (`install_requires`) 8 | # TODO: search inside Pipfile files 9 | 10 | 11 | def parse_all_requirements(path): 12 | """Yield each Python requirement inside any requirements file in a path 13 | 14 | Will search recursively for files like: 15 | - `requirements.txt` 16 | - `dev-requirements.txt` 17 | - `requirements-dev.txt` 18 | and for each of them, yield each package 19 | """ 20 | 21 | if not isinstance(path, Path): # str 22 | path = Path(path) 23 | for filename in path.glob("**/*.txt"): 24 | if "requirements" in filename.name.lower(): 25 | for package in parse_requirements(filename): 26 | yield filename, package 27 | 28 | 29 | def parse_requirements(filename): 30 | """Yield each package name inside a requirements.txt filename""" 31 | 32 | with open(filename) as fobj: 33 | for line in fobj: 34 | package = parse_requirements_line(line) 35 | if package is not None: # None when empty line or comment 36 | yield package 37 | 38 | 39 | def parse_requirements_line(line): 40 | """ 41 | >>> repr(parse_requirements_line('')) 42 | 'None' 43 | >>> repr(parse_requirements_line('# Hello, comment!')) 44 | 'None' 45 | >>> parse_requirements_line('rows ') 46 | 'rows' 47 | >>> parse_requirements_line('rows >= 0.4.0') 48 | 'rows' 49 | >>> parse_requirements_line('rows==0.4.2') 50 | 'rows' 51 | >>> parse_requirements_line('https://github.com/turicas/rows/archive/develop.zip') 52 | 'https://github.com/turicas/rows/archive/develop.zip' 53 | """ 54 | 55 | line = line.strip() 56 | if not line or line.startswith("#") or line.startswith("-"): 57 | return None 58 | 59 | elif line.startswith("http:") or line.startswith("https:"): 60 | return line 61 | else: 62 | return REGEXP_PACKAGE_NAME_VERSION.split(line)[0] 63 | 64 | 65 | def print_status(text): 66 | """Print a status text message, cleaning the last printed line""" 67 | 68 | if not hasattr(print_status, "last_status_len"): 69 | print_status.last_status_len = 0 70 | print("\r" + " " * print_status.last_status_len, end="", flush=True) 71 | print("\r" + text, end="", flush=True) 72 | print_status.last_status_len = len(text) 73 | 74 | 75 | if __name__ == "__main__": 76 | import argparse 77 | import csv 78 | 79 | parser = argparse.ArgumentParser() 80 | parser.add_argument("output_filename") 81 | parser.add_argument("path", nargs="+") 82 | args = parser.parse_args() 83 | current_path = Path(".").absolute() 84 | 85 | with open(args.output_filename, mode="w") as fobj: 86 | writer = csv.DictWriter(fobj, fieldnames=["repository_path", "requirements_filename", "package"]) 87 | writer.writeheader() 88 | total_found = 0 89 | last_status_len = 0 90 | for path in args.path: 91 | path = Path(path).absolute() 92 | repository_path = path.relative_to(current_path) 93 | for found_inside_package, (filename, package) in enumerate(parse_all_requirements(path), start=1): 94 | filename = repository_path / filename 95 | writer.writerow({"repository_path": str(filename.parent), "requirements_filename": filename.name, "package": package}) 96 | total_found += 1 97 | print_status(f"Searching {repository_path}... {found_inside_package:02d} found (total: {total_found:03d})") 98 | print_status(f"Done! Check {args.output_filename} for results.\n") 99 | -------------------------------------------------------------------------------- /requirements-development.txt: -------------------------------------------------------------------------------- 1 | twine 2 | wheel 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | tqdm 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Álvaro Justen 2 | # 3 | # This program is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Lesser General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # This program is distributed in the hope that it will be useful, 8 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 9 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 | # GNU Lesser General Public License for more details. 11 | # You should have received a copy of the GNU Lesser General Public License 12 | # along with this program. If not, see . 13 | 14 | import setuptools 15 | 16 | with open("README.md", mode="r") as fobj: 17 | long_description = fobj.read() 18 | 19 | with open("requirements.txt", mode="r") as fobj: 20 | requirements = [line.strip() for line in fobj if line.strip() and not line.strip().startswith("#")] 21 | 22 | setuptools.setup( 23 | name="contributor-network", 24 | version="0.1.1-dev0", 25 | author="Álvaro Justen", 26 | author_email="alvarojusten@gmail.com", 27 | description="Create an interactive contributor network graph from git/hg repository", 28 | long_description=long_description, 29 | long_description_content_type="text/markdown", 30 | url="https://github.com/pythoniccafe/contributor-network/", 31 | packages=setuptools.find_packages(), 32 | install_requires=requirements, 33 | keywords="free-software open-source contributor graph network", 34 | classifiers=[ 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", 37 | "Natural Language :: English", 38 | "Operating System :: OS Independent", 39 | "Programming Language :: Python :: 3.8", 40 | "Topic :: Software Development :: Libraries :: Python Modules", 41 | "Topic :: Utilities", 42 | ], 43 | python_requires=">=3.8", 44 | ) 45 | --------------------------------------------------------------------------------