From 2cf2dc2ac50bc7dc4f927f6362c17527f0a265d3 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Wed, 4 Nov 2020 22:50:44 +0300 Subject: [PATCH 001/100] Version 3.3.1 dist --- build/lib/ipdata/cli.py | 240 +++++++++++++++++++++++++++++ build/lib/ipdata/ipdata.py | 46 ++++-- build/lib/ipdata/test_cli.py | 20 +++ dist/ipdata-3.3.1-py3-none-any.whl | Bin 0 -> 8754 bytes dist/ipdata-3.3.1.tar.gz | Bin 0 -> 38693 bytes ipdata.egg-info/PKG-INFO | 65 +++++++- ipdata.egg-info/SOURCES.txt | 2 + ipdata.egg-info/entry_points.txt | 3 + ipdata.egg-info/requires.txt | 2 +- 9 files changed, 359 insertions(+), 19 deletions(-) create mode 100644 build/lib/ipdata/cli.py create mode 100644 build/lib/ipdata/test_cli.py create mode 100644 dist/ipdata-3.3.1-py3-none-any.whl create mode 100644 dist/ipdata-3.3.1.tar.gz create mode 100644 ipdata.egg-info/entry_points.txt diff --git a/build/lib/ipdata/cli.py b/build/lib/ipdata/cli.py new file mode 100644 index 0000000..c563a28 --- /dev/null +++ b/build/lib/ipdata/cli.py @@ -0,0 +1,240 @@ +import csv +import json +import os +import sys +from ipaddress import ip_address +from pathlib import Path +from sys import stderr, stdout + +import click + +if __name__ == '__main__': + from ipdata import IPData +else: + from .ipdata import IPData + + +class WrongAPIKey(Exception): + pass + + +class IPAddressType(click.ParamType): + name = 'IP_Address' + + def convert(self, value, param, ctx): + try: + return ip_address(value) + except: + self.fail(f'{value} is not valid IPv4 or IPv6 address') + + def __str__(self) -> str: + return 'IP Address' + + +@click.group(help='CLI for IPData API', invoke_without_command=True) +@click.option('--api-key', required=False, default=None, help='IPData API Key') +@click.pass_context +def cli(ctx, api_key): + ctx.ensure_object(dict) + ctx.obj['api-key'] = get_and_check_api_key(api_key) + if ctx.invoked_subcommand is None: + print_ip_info(api_key) + else: + pass + + +def get_api_key_path(): + home = str(Path.home()) + return os.path.join(home, '.ipdata') + + +def get_api_key(): + key_path = get_api_key_path() + if os.path.exists(key_path): + with open(key_path, 'r') as f: + for line in f: + if line: + return line + else: + return None + + +def get_and_check_api_key(api_key: str = None) -> str: + if api_key is None: + api_key = get_api_key() + if api_key is None: + print(f'Please specify IPData API Key', file=stderr) + raise WrongAPIKey + return api_key + + +@cli.command() +@click.argument('api-key', required=True, type=str) +def init(api_key): + key_path = get_api_key_path() + + ipdata = IPData(api_key) + res = ipdata.lookup('8.8.8.8') + if res['status'] == 200: + existing_api_key = get_api_key() + if existing_api_key: + print(f'Warning: You already have an IPData API Key "{existing_api_key}" listed in {key_path}. ' + f'It will be overwritten with {api_key}', + file=stderr) + + with open(key_path, 'w') as f: + f.write(api_key) + print(f'New API Key is saved to {key_path}') + else: + print(f'Failed to check the API Key (Error: {res["status"]}): {res["message"]}', + file=stderr) + + +def json_filter(json, fields): + res = dict() + for name in fields: + if name in json: + res[name] = json[name] + elif name.find('.') != -1: + parts = name.split('.') + part = parts[0] if len(parts) > 1 else None + if part and part in json: + sub_value = json_filter(json[part], ('.'.join(parts[1:]), )) + if isinstance(sub_value, dict): + if part not in res: + res[part] = sub_value + else: + res[part] = {**res[part], **sub_value} + else: + res[part] = sub_value + else: + pass + return res + + +@cli.command() +@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') +@click.pass_context +def me(ctx, fields): + print_ip_info(ctx.obj['api-key'], ip=None, fields=fields.split(',') if fields else None) + + +@cli.command() +@click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) +@click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), + help='Output to file or stdout') +@click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', + help='Format of output') +@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') +@click.pass_context +def batch(ctx, ip_list, output, output_format, fields): + extract_fields = fields.split(',') if fields else None + + if output_format == 'CSV' and extract_fields is None: + print(f'Output in CSV format is not supported without specification of exactly fields to extract ' + f'because of plain nature of CSV format. Please use JSON format instead.', file=stderr) + return + + result_context = {} + if output_format == 'CSV': + print(f'# {fields}', file=output) # print comment with columns + result_context['writer'] = csv.writer(output) + + def print_result(res): + result_context['writer'].writerow([res[k] for k in extract_fields]) + + def finish(): + pass + + elif output_format == 'JSON': + result_context['results'] = [] + + def print_result(res): + result_context['results'].append(res) + + def finish(): + json.dump(result_context, fp=output) + + else: + print(f'Unsupported format: {output_format}', file=stderr) + return + + for ip in ip_list: + ip = ip.strip() + if len(ip) > 0: + print_result(get_ip_info(ctx.obj['api-key'], ip=ip.strip(), fields=extract_fields)) + finish() + + +@click.command() +@click.argument('ip', required=True, type=IPAddressType()) +@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') +@click.option('--api-key', required=False, default=None, help='IPData API Key') +def ip(ip, fields, api_key): + print_ip_info(get_and_check_api_key(api_key), + ip=ip, fields=fields.split(',') if fields else None) + + +def print_ip_info(api_key, ip=None, fields=None): + try: + json.dump(get_ip_info(api_key, ip, fields), stdout) + except ValueError as e: + print(f'Error: IP address {e}', file=stderr) + + +def get_ip_info(api_key, ip=None, fields=None): + ip_data = IPData(get_and_check_api_key(api_key)) + if ip: + res = ip_data.lookup(ip) + else: + res = ip_data.lookup() + if fields and len(fields) > 0: + return json_filter(res, fields) + else: + return res + + +def lookup_field(data, field): + if field in data: + return field, data[field] + elif '.' in field: + parent, children = field.split('.') + parent_field, parent_data = lookup_field(data, parent) + if parent_field: + children_field, children_data = lookup_field(parent_data, children) + return parent_field, {parent_field: children_data} + return None, None + + +# @cli.command() +# @click.argument('ip', type=str) +# @click.argument('fields', type=str, nargs=-1) +# @click.option('--api_key', required=False, default=None, help='IPData API Key') +# def ip(ip, fields, api_key): +# print_ip_info(api_key, ip, fields) + + +@cli.command() +@click.pass_context +def info(ctx): + res = IPData(get_and_check_api_key(ctx.obj['api-key'])).lookup('8.8.8.8') + print(f'Number of requests made: {res["count"]}') + + +def is_ip_address(value): + try: + ip_address(value) + return True + except ValueError: + return False + + +def todo(): + if len(sys.argv) >= 2 and is_ip_address(sys.argv[1]): + ip() + else: + cli(obj={}) + + +if __name__ == '__main__': + todo() diff --git a/build/lib/ipdata/ipdata.py b/build/lib/ipdata/ipdata.py index b339125..d6306a8 100644 --- a/build/lib/ipdata/ipdata.py +++ b/build/lib/ipdata/ipdata.py @@ -1,22 +1,36 @@ """Call the https://api.ipdata.co API from Python.""" -import requests, ipaddress + +import ipaddress +import requests + + class APIKeyNotSet(Exception): pass + class IncompatibleParameters(Exception): pass + class IPData: base_url = 'https://api.ipdata.co/' bulk_url = 'https://api.ipdata.co/bulk' - valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', 'continent_code', 'latitude', 'longitude', 'asn', 'organisation', 'postal', 'calling_code', 'flag', 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', 'status'} - def __init__(self, api_key=None): + valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', + 'continent_code', 'latitude', 'longitude', 'asn', 'organisation', 'postal', 'calling_code', 'flag', + 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', + 'status'} + + def __init__(self, api_key): if not api_key: raise APIKeyNotSet("Missing API Key") self.api_key = api_key self.headers = {'user-agent': 'ipdata-pypi'} - def _validate_fields(self, select_field=None, fields=[]): - if select_field and not select_field in self.valid_fields: + + def _validate_fields(self, select_field=None, fields=None): + if fields is None: + fields = [] + + if select_field and select_field not in self.valid_fields: raise ValueError(f"{select_field} is not a valid field.") if fields: if not isinstance(fields, list): @@ -24,6 +38,7 @@ def _validate_fields(self, select_field=None, fields=[]): for field in fields: if field not in self.valid_fields: raise ValueError(f"{field} is not a valid field.") + def _validate_ip_address(self, ip): try: ipaddress.ip_address(ip) @@ -31,14 +46,19 @@ def _validate_ip_address(self, ip): raise if ipaddress.ip_address(ip).is_private: raise ValueError(f"{ip} is a private IP Address") - def lookup(self, ip=None, select_field=None, fields=[]): + + def lookup(self, ip=None, select_field=None, fields=None): + if fields is None: + fields = [] + query = "" - query_params = {'api-key':self.api_key} + query_params = {'api-key': self.api_key} if ip: self._validate_ip_address(ip) query += f"{ip}/" if select_field and fields: - raise IncompatibleParameters("The \"select_field\" and \"fields\" parameters cannot be used at the same time.") + raise IncompatibleParameters( + "The \"select_field\" and \"fields\" parameters cannot be used at the same time.") if select_field: self._validate_fields(select_field=select_field) query += f"{select_field}/" @@ -57,8 +77,14 @@ def lookup(self, ip=None, select_field=None, fields=[]): response = response.json() response['status'] = status_code return response - def bulk_lookup(self, ips=[], fields=[]): - query_params = {'api-key':self.api_key} + + def bulk_lookup(self, ips=None, fields=None): + if ips is None: + ips = [] + if fields is None: + fields = [] + + query_params = {'api-key': self.api_key} if len(ips) < 2: raise ValueError('Bulk Lookup requires more than 1 IP Address in the payload.') for ip in ips: diff --git a/build/lib/ipdata/test_cli.py b/build/lib/ipdata/test_cli.py new file mode 100644 index 0000000..b1a56a0 --- /dev/null +++ b/build/lib/ipdata/test_cli.py @@ -0,0 +1,20 @@ +from unittest import TestCase + +from ipdata.cli import json_filter + + +class CliTestCase(TestCase): + def test_json_filter(self): + json = {'a': {'b': 1, 'c': 2}, 'd': 3} + + res = json_filter(json, ('a.b',)) + self.assertDictEqual({'a': {'b': 1}}, res) + + res = json_filter(json, ('a',)) + self.assertDictEqual({'a': {'b': 1, 'c': 2}}, res) + + res = json_filter(json, ('a.c', 'd')) + self.assertDictEqual({'a': {'c': 2}, 'd': 3}, res) + + res = json_filter(json, ('d',)) + self.assertDictEqual({'d': 3}, res) diff --git a/dist/ipdata-3.3.1-py3-none-any.whl b/dist/ipdata-3.3.1-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..cb94e82f6eb181855ede781a3b2cb26c8d01e584 GIT binary patch literal 8754 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 literal 0 HcmV?d00001 diff --git a/dist/ipdata-3.3.1.tar.gz b/dist/ipdata-3.3.1.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..a2adbd147b9ed06cf3b7e66f1242f83e49045822 GIT binary patch literal 38693 zcmV)JK)b&miwFo8`l4R~|72-%bX;k0WMOn+Ei*1NE-@~2VR8WMT?ZhQZ`)5sBFRdr zILV0YP4?bf_Bc2=*5RCEhcb(XmF$&~$Skr)St%nSWS0>VvNFzh&Y`{gzi;3BeXmD3 z=XvhyzV3Zr*YCc6_k)F%g;i7)=qd{WnuDOb{w){V*7dzlHa1SKkKa+x+1a_cId%bD zcm0LS2?hs3k;(t73;BQK;@|_=!NGPQA$A@%E^c-nPF`*nHeN0+E`DyjT|3wR`wQ$~ z4uk`lIaxSa*jab|`^G;nFYouq|Cis{kr(GK0Qb)L|F>T({|WOy2hZ2@Kj+T;|5xUJ zj*s&{Cm#ntKN|-N+s@4Y-#Py)%1KBmYe=!cUE%-g@z2A<^S$xU!N&Ea|Hr}0$+HW< zwln_!?Uw|^!3_$ww1NXT*w}di@(_C<+zMz9P=LTJL0|$r0z6d^)D8@Tfg$z)FbrS? zf`Uxl0G3doJsf1t1h9aDKmdpZz|0B=wFEH%;1B@N-VNXYg2IpyAf|92*dA=4j zkSU=g;Z{gBFo*@*1qcNph0Os#7z|u4rZ~znpgG0e)C>=~lAv0Sib5tJQ2it<}z}qRH zqN9rsLn(EFAxnVDClkO9Vh*-IeS*-%a&R)W1;ebE0OnwnYEvgT@*xcM&P38I$aklh~4KUK~|B46Vx7=BnTbH9D=Me zbc)s>GdSuAO56fsYYTBf70nD{Zw^M42qu7PGfiY5&=lefLYHQ%eeEG|WM;QAfocMW zkL|i01hWDnb8iaTt}%s}NNZ|~ zsY?OmGytmVDre**r6d8=Vj9SMY9@fToTjXbmL>oxp)RJZsS8k%28b!^0u~ya8y)iYY3hlM>TH=3X70KM568U3EDbSxtbfilU?x z@}amCGG}7qic(uiAqyp;C?==G1dtR{5|fca$5KIRQAdky=T2Kz3jGL~o*43e4_({#2M{$4dVHy>|Tn9shsF|KIWd(f+@Zn6jL-l!oTN zVf_EV|7T<8=Ka$D=iuVq@&EtEbpq{V17xI>rPRext`yR_NBOu;aIh_m3FUX9{45aC zxg@|-mlBgyk|Mx^f#6OKEM^v#@4q^@5#VigBvxZ%q`wb0HvaV4)(85@&Vbvo|2z9X z|4H`$oBKc9TwMITJA?JV&Hk$@$S@Odk9&iQ( zMXd!10619K3GkGGb|3-3R_7qV`w)iiCIomIPIh)csG9&l)eUY1u?I-lf0z6rW9f;WhXo*yA1&2Gp1Xx)u!Eh@lQx-Fb-L|8^ z>VVctfG6gJ{Dca8y(qaAzznhjf^7u=)?2}%+ir##gaA)*YgtYJpd_bBfF}upnL)u0 zaC8w)ATtGrqt?tckPQz9nG@gvwy%%hG*Js=$bZ21B{ciIg!&)SGCcXd93LJre52$n z?<)mjVS%hnq+$yo49*0w0$Wiyfc9=o$jm}PKsZ3o9uBg#1zUpbw^#Y>o$NqRh!YF|L1q~WKqn7` zZ?9g$SiV(mWe6MuaDzAj>_H%Nq!M&?6hLn1WnD8fWIoWvM;AyF0~zKs0!60NEs-=0SZFaEEFl}V2jN7HGO7zE8HiwwOSUzfGKxe=Xf?Be*M}D>2%Ko=>pE!YRBIIOaYm1iomWYXo z$;TT9umdMjDLTf7k@!u?&(hF>AxGD@!%iT355xx5^|c((%_V_Ujk^8V&3>9I3n;|y zeP0IKp_YKZ${Sk50a@3`0kbW#b@xeL01~J^i|VIaMMU*khz0s~dz_#bx>*3~AY^kQ z15J=!7H+aN2GH%j-7`Px+>R);{e?wNRTBAek{%V2p5db$vX~AKc!pp|O&c#LlPpVjbTk@y1=|K;K+0zRE z=*2X6*m-#AnLY*q=*_meys;U?9E6f#|1uZ~MNU=75D$7gGc%L~Cp#NG6M){%9(~8g z#)Z1GN7_f!Ez&ZA-68h$o}X(0!`)D!(m*J33Whm@ZEZncqyw=>+92fg@*yM2V&9W^ zpO!MR(X9YtNR41K;FrQq_HcA@xY>BV5d?MnkdBtdx5MA3qJ@O-$Vn7w9FcAOg{Tv9 zeziAqL)XqneUhI^eRh4QCoK(tBm_D0L4PO<2RoysX?`H(VCw`0+HUJKN9%AxZEWDovO?o;g2?9yU@txvws zrQd7)+619OV609s76*IF&*Oj{P%%CmE}w_Pj6qIF5ow?;4D?wD(%xA*A!mIUGF0E= zL!C?6TcY+Xx4P^5s(t^^)9?!wa4_5nT{2Fji^s#w|9LRP-tv7oGd~XtCl4RbX95tY zCD0xWLt8dfG7?Bv#0&yZMS58F$Pr)#b^u60D$Vmr=9N}hgV|0}WeLg|_B{$&9Nl69* zu|z^FMMVkJocQUa@O#Y-L_sT4Bp5-~Hp~HJ2DSj3Z8be?r|nT~zf~7P`Y4;m^m#Kd zeeQ4={-v()8(z+q9sW?3A6dCiJJk2=&98MWaVJ|FG=Ok&`0wmPrpO8~-tI{HA8VhU zodx~#*E-63rQ5#L2PDDH&CbLBLreEVVDY8!7C=x&+KeAb{R||0SN`Xa;j5_M0}$U5 z{63ucj`Yum-8cP)-+>x`4m|z@hWsN?@~aT#3%TC{EZ>a#i}2-(xPJgXfTK<~N__9O2heAJ@B0iv^Uy^%2 zYJVd9XQ=4UFw#FlO#cu+{TZ72Lu~cyNbAei_ygSa8z_uVfQ>_di;snklbeg<=P2yY zFxfvwXg|ek-;MgI4d>+K-5*l3|GOsqlXU;wl#6NofibU5|6tXB-in8){2#134+kIbDR#C$v+BQI_HRzytQv0S_8_-EvFpezqwTukKU!D;hyr2QB%uz^QMU+d3=*S0|7vtwoZ=0zbErK)d5Yi;Y3Rtf5I02dHZfJI7ukV zeMj_4QiV$8Qo4J3FAg`4@^zfp9Y`lg}QZC?sD#0mDCkVh8%}BI3u& z07$t4?Hp`Du66AwIDC)~U&_v;SG0zh7)bMgj}lREnt*#rP`MNSe(0VvS!{i#v2t%^e4epI)$!0z@wgDl@I(bnqe*1{yxUZD6F zz%Th1-zd~~0wK^JSA`wuJK-Plf+jlsay9^hANeA}zpDDZW;ET)0&EKhK~YPqPPTB= zl(k)RTgOA~fB+Z>RoSTZxA!y0_B{9N83QnH&sWGmrcbfNk8&)I>ga&2wcVd2g+7ab z?vEd<4TMe>;skeaf^SP8yUW(S@UN!3rF2^fGP;Du8T7%2e|3I7gfMTB!O#i)W~G_D zmp1`G&@&KfMn{qAz|gHDxn@8ZdZiwvdn>iiMujTU|3#%P0MJraP*%}aCcu-hMUF!- zQY%ydu+5DG2nYaFG~@tc4oLsV8L3zR&6a#0{CONzD8v#uPVG>ANpag;rIgq-hbC| z2Lil*-SGwjyswTk5a6M`*l!+NK!>2?Y#&cRegCE72-|UgKzaEA)#YDFarqgwr&|9G&e)-=gtwlkt^GjTtZ*0qbk3aJZ zOqt){#QX~T<;R<3zsP9$n!EDF#@3s}z*6M@d-R`#{e*?`JA9Mxk>hkI3-fB*A}^{Qe!%`&VT5-`x56*&fd??&kdRzRk~eXnwjE^Q~Q%-y%)?D@f`; zBSQQJ<>8xT^iS-?_ekge4~)gHlE;6?i2N1_{2SH$L$de(r#k;V()RB(!7tdhuNi_r zBhLMx={_e@{~vCnUm->RPDA`Ix%rn!%|GTr{VIw156H_uWj*~GS^2j~%708w{(VyN z?=YGEh=lwz^6`({xqnVE{&Q;aPbtMeV;}v?SVzA?9sV`S@b6KDf5s;I8H?zb*hBw_ zHS~LIp+8~?{UJN(_gF!{$p-p43+NwEdjIuQ-ru9}{xNm;S17x`K-K*p-2O4u_RlG{e`t_?pHlmCD(%;%DIH~=e%&`mZOZ{w5*y&xxqtBcOg4Y<`1aN`UttLMTP&?>&L^ z(_H-p!YDfJZGtEP-q(cC_nSrEA%1?W-%kmi1bDwi=p?}Vl)(9O;wAy!XGG0U2$~;> znI8$6TSUyQ1&41EFVR6iAX=i!_#I*;I_zIWr2Lvdxt+5g5hg#$-1ZHM5cz4AQ3Ocz z{f`KbpGNqc;P~yR|1v`3_Y(dA7w=<^J|`@Grs@v}ir?1zeL~_tOYb)ci0H)rjBvQa zo!R04{j1*p%=tC{k9~*#_gDCTykEZmnTv&=gM*8UeP@9G*YW>QFaBr!_l*DF`}?09 zY}`CMfB$ob|Gm@xf0O^c^Y?#t=70G1LMJmj3mXe^WUyGe|C{;W$kEUK<@o2~(1mC`c3HpC?k!@%A@qR%-Mt2SM4`z^25ON@2f661 zf|RfB-#>ViU+$^((3)QiB6d(l7Smg3bFGrsY6Ky2!+XJTT=C^W=ymk(roz$s<|U+8YTCwRvR(Yy-Xc!h!gNn_RB_u z&aZ7Y9Li|k$J$g8&l=pg8yiC@6VIoJW-cYzq?15b#gd7(Kg``a{ORkccrGjXh6A+$ zW0psBbFkp6WlDL}eAME*H*dttQM-$VGJ5z-*Y>W)bW~2=yJ9)0T~L3i-@H5|Vm?Mz zKARVl0Io(3JF+Yqgny*bUDVRpNAj+delymPJT0F>3Y0%?{#86S$=)+|4>KZMvdmP9 zU*G7Es!Ql7-4$=x?vF{A%W>F`iMk`RVzzYYJ}2%?94Wj9vos2QeTs~6)GoJbL&`$l zTxQ_6*w^U?SAP6ljo&v<(%h$wQb1oVkLTjkprnw!V&eWU1hiJ{ZDd%iZXM`}3Ad@( zqh*)d@nQ}_j!;P4a6UW~yolJ$dx#itNE$^n_CZDv8r9-?sTY=RHe!o#8O>F_MXWY8 z56n&1^~q!+5RVWWbFRgkR4Ymd1kIatD}+atXy&eHHy^=%MqWO2NuaI_BU@{+`2UA7E{0J{J-ILglR32T$xxpToKqvzpH+d%K&9Cex3(#ge|T zxT+;v!a25npG?IqHk4**r%DKXy$9C@d8gMZpCFb4y>261gk}(C-fb(0@Rx`S^4AzB6dbU{9tr@8 z_hcPnvm#iA$I>KaS+T0)9%ws0Gbym%Q)oG!7j=tU&m0`(QEEE0`_@?zQuX6E2M@1^ zmF}~iy>#WSrK^GAOiE4;T;HLrR&^;ORPwM)4C|e}3ybTA&Oblk3_t3~$nZ#|J&sNJ z2~YU`J-7L15B2Y(X1ud918B>Q6Wiq(&acwYMA)65Z3u)<+326?#Es;QFoe~JQ-eGs zN4ZRsLA{Yjv(_quo<@(SXx)C~?^%{Id`ri|*qQh7bkKvjw~FSeJQ>uHnc(>Txpgbw zR`m<|;v;;rO?HYJ)G!?W0WL?2L{VexMlI<1Ls8Xw$I^S_@t5Z{Qe%nWAr72&YrIzd zHW4MOd<(uk!+qm5>qFC>ON<@Xg?TYf`*oveLqvi$)m6ltOD+QRFx9+NoELHIwBNPg zNmLCr+MjNro4yc78g||}`+4MIyp5ENUCcepso+t*a*WeY^h!^TvzE2RGWN&P9RRd( zH!-Wcs(Z$j#ed@LW8sOi4HNZLi+!P7xvs}c>fFb1<9NzN*a1{7 zS~#~*^3-z5*YZgmd05(#`G(CzO$lE|;Hf9zH`feALRA+oUzy>PesQHCDt)YUIQaOy zAJb)%#8~R8v0IrhHFazumOZBRG(?OmI>swW6RBM%cl`ey|9{8--?{!n{D1bphR!mUT+vFL-VOOkisYw+fzKl^(sG z=bh1CIk-7v4@5X;nN1IZony(K!Qv4w{W;d`dgY}7mBh`sMd+3l{PV8oGp z1lLP(pcvxOO-Nv4y5BAYv6*ZZ7+C3x z=X)CFuqB(z`uoivVGY-Onk=1CPPv2!QL$vO;o3;2axG5FVIRM)t7rA_3HG&!sK*Ga zy!MosW{{0uoV)7n{fp(CZC1(s>YNy$F5Jy1#v6eUkqVI!Q+ds6X`Yc?55ddVD;v%b zkee-$lReYZGF%y&XAJkfKnSjywd0K)cy|;lPb^gQC2JUc=S$^>98$Sjy5hMfVpeaRr7;n-PB zQ}ZC_>QmSk4};Gp&0z z-iZ+jl$JK1Bt@!=3-CD3V5ph>QXMPLnx(4TxHq{n zh}T>5YO|^n&v1PZOPI95d$Bc7>s%vNRv%&8@$CIm9#)wzGvTk zM1aZQ*s_?Pwz$ZJz9Ih8AH*c>jt(evIM^;(dw@Q0PaqY7PqJ*?h8V_?op`j$`|MD_mBr~Gp11F?mftiC zw!L15-wD(%Oz#iNwop06YMekD+uPDr@T%8ALAMLsnCt>M zr3gY_`zA!p*gE~RbLka?-R=`L8Lo|~4@rY*vt7j;9qCQhG042{Ny;XM;mZefhF-Z^ zhq0QaovWP0@IqDaVOV1b(Y1!TktH2jY#I!6fwFxE?V~r&o_6x8)N}C4x$0M?e4&=> zuzXya&N5!=gcZ*4K{!rADt{_OOBBE1iC~)CH3boDk5yy7Sj1tDWbWMkIPbhX*q+@Q zKFg?I8BK2c;8AK;TYTh{NgV5eT%42$$?I%4ujFQ&@j8PEP3Fpq^`Rfew|*K?%HE`M zS4rlDk1=?}`5Fw|pFfNzJ17*f&^6-Sn5Crs;Eid2GoG2z$zzAQvZt~aEHvz1rjH9> znV!68$&tvKXKe@Hi0S4%s;!bU)bC5b!}E zTp-25keH|M{=sKgu&- z<3Elq{CC(8*2lxtx=vO;Ce{?Mq>vASR4(*GmAWhpHH>@Vgu2Q=nqG?&7b)bp=iK`t zRI>5~(|4wdYznS&t^@Y&5)0XdabQeJoO=f6MN;vC>Ja9o+BQL7wTa?H?rE$I6RtP+ zD+j4wuha~gusyFCZaE>E$#w0}Y&i*kCw`3gm05+i?#CAwM0TGAdDp>pLtJ^han}g< z__p?Wv)!5&nIOa*!VlOy`K)HMYm;z4`QeVI_o`{-qk?EzxyapI>NNuBSEg6qOuS@o z3X5l67fE*7?4QC+JzqtMxX<>ccn*=6U*!>Ex~elLGO>1FV3Sa07~`4u)7fQYklJ)2 znYcAm4=b&P-f_|lO@`?>lS^#X+*(YGAiULTeJjJ8j=3`U%8W7>rDic%g_nd=xe~9# zTZG-6b!sj6f?QjiNM{%#TLcbC_vgKQq4^?P1EwMv>9J-{hZzvgCmq#RkxeocDw<>5 zefZ!dIz`4L?>F*w=Hz_PnalawZXWqeuC4UKd&{q0&dEH$3sM6=433o7W6`F3nM$)@8lXt^m2DPH6xv{WfLlfI9~X#|`(Zx@BCwR9x2)yoxYHX*76506F-GK9|F#s^!)^iKxVzBWCRZE*1({ zKkVKpg>Po9?WH*uufm}rJG?3E`}rYZbre~koXP&)Oa3oP>TL-!A}#jkEcn^!Sf zqq+nWN4xXzV`!}k_T%>KV%9wlzzJSH%5pJkWKx|K5Y)gB_#o&wf6t1imi8VckOSH9 zEUnqZVUo-XnFJew9V9+&a{Jd3(hvHVGK)@RKO&;I8cMK)&C2FduBGPFHh1iCPz1vr z3XMX{<Zb!Pdn#-)d@ zz}tA|g{j>VU8$P$|X4z1bD-xl^50OddV^_O>PgpDQMdIN^8`zsZyZA=@G(3@d<RM zw3lO$+Y)=D(Jb;*)X4hVy2-3W<@V5fk2S=?U$6FDHBPu^X)5I1>Yiu1`?c6uRmu%5 z7dY(_w*CNK^i6ijEQ{!47ch?`F2yG^(CmeU>+fAzUPM%x4vwoNAbiE1y+*8#tzWIR zLyW7L&`AZ590yPOs7eWP2*_ zdS)zNXtMX^>?;IjCKsvWIZiFfr|%NaCmGU@zOoL7JArz47vtk~Zwjj2W6BG~2aUn^ z?xlDekY_f63%=vPss$4#Wf~+2A~oTjSANqeQ=6vd%^LKm>ZsbC!5Vv^wFfNwpUkCm z&~;0AwsHBqI>wlreDX@Px}B}qg5-l7cPGt`Ww1`u(_3A4I9SU2u&ft@8-d7*P zcDLAj*w`z`X;CW4VnnrOJ`FP+d5D3*6g}ovuCFsBnv2@2Hw<1aJgQZA*@fTqB+h){)yj-i8&i&aMtfq!yNiC- z^a683dJV6GBIcjl@d_qSSUSMuOcG)ltMoeVKpFQfbrstBJ6`OHzWUH+f0~fN0c-Y& z%05LNB8yyG!lU-baxR|5kb(9ApVuBN%JXMl&mD3Z)xHb+-`RlM!GAmWZwLSFT>p9a zkMr;G|G0PX-~S^1EBgliwF78rikH+2pp;ob%X$ojMADntO z=t4@X>)d9}YpSvuGwPD`*p!O2(T2WwGu29XGB3qZ zWgO;KD#|PS^{t+|?`n*oBTnwpPav+Kn;f60^kXGd_d=YgE_$b}X}LSFxzlsPTkxot z5#qjFGGf?d8Z%yGi7v@Iw;fS&-%XsGoz7ucQqwy&K7?z#XD2Tk~mQ88&V>kIS+Qmm{nSJ@E53@x)Bd!ftvLxT)|0(39)1;_l zZ2HDBFnWlFTDt^2ukEdq&Z`U*nl7U{rEAub%c&;&P*RD8nYZseMp1TfNpRVTx)OYI zp(GfqXi9HsJc!sKjrdK_D}((8MnXo==mY&0ruCA=0!y~z!UBex)BIWZZ9U|MrfhEU znSd8W@sFQNw8CZ+#KgHzY5=^lTzdyV>cdhfy~|dN-%S@c?=sFt z5C&>(E(g*fRO&ZxUPKULC^Usr_uil;UvG1*Lp(u{TdH_uEYP#grfniP5mZWaYl{ez zY3@zLfj221_bCt5dz8W_=kPc?Q-&V-5xHEv)e-yF1_x2AWFLhaIUf`A;Wj!zSB`l<3k3+u2rIfE{q>ywZQj=!Cx;sf>#H~27E5%B2{q$_w7|?45035o!F279Y z@#keLq?68A$7r4__H7X8EmMH(gJ`kkm}4g_#G03BH;!Rw zWa%c7v;jPvZMr>>)Gsd*utdVh(OmQNXoGV?hd4*umGHiT64(u^8%jr(4PcHwe%&TA z(hB1**QLnaHSF9wuy$jWrj)#7D2#e$!EGs-v*H0Op0x|&x^z5j%wRct9FpD~N7mF4 zKU{Df*X9{OB_LqTUf@yF5~YLi^>+raH;O$A;ssm@wqDFaeAkD@9HayYD#NDeiLEQ! zGmkV}7zm2WduqgccCTbYNCwd~%u>r0Y`ot%|HHN8|Npi9AGNRjf37Y6U#HK0l;^-=VWsIg;YJK5-5_RsHj^Z=@c#6k zMI3OkC+=?FNUPW*a^`Z3M<5~IvlC? zoiMRkJ9P^-EM2%NJS{S@oLKBFl09>^Ygw&@$`zQ4osf?haEw=1!Xv+zZ+eTm$h?S2q(QlJUsGgob7#r!m{kJhK;?hVQE zwdx0E%g2xR2-3seSzW(xD&X7X2U1j_vy%$f-Q)0-+3-re;Ei}`ck_-%!nZ6KRb@-B z0hD#LicZIdKI_*k#y%MFK$CIWI}aw6=1Ux16ll^tz!K@>B-9P_TywnJ<+sph@bnbO z)J#r~rJD--nJp~3&r{`$quq@tU+5A+j+NQ-F1CxFmx-S9cfJ&OJ~q*R_&P(o9i6ly z{7fIEU3`Puo>%-bvG{zwhNbZKmrgl!L{}%R%&9yQHsFNBg<{dkZ|Lg^mx{u)64RI6 zJ0)gMFm~m`a6x<9>dGxXwUZUf?Ryk`aO8`I6IAjHNH-#+|Js-tTG2tr##b8vCX>dNE5iWlDA zAOv8a%R-gW0g5%M-Lq5Ui3_I!@XW`KujD{3%LTnPSPi(_-cjt;(h*@I=+~TP1PDch zuwA-+ZfZ^d=O~T* z%PzSq{zq$77JKevYr`JCat=)Qqm{5?TG(JboS6FHC%c?&L-dqjz7FE2wdA z9ZAysp)y}3AIW0Z@LXmoIQ(#$4}| zr|dAT34Yuu$+aqf+gn`frR2DCm`Ob|ac#k?d-*h0g%>Q@TVVQ}8sHcLp4yrv9+6IF zGAXUG6VQiuBx8&-#V)_RAM#jdv7oeaQipDahwamAh3Czl+gX>M{#P!S@2vmt`2Rcp z|IYQ_;Qw>~ub%(s-tqtcmj9pijr0H9-#q_+>XY;TH*AVOJO7`8@RU*CKL6jPv`jtc zx^@1)9&h<9>qPNue4Jh3fHq@q;(f0MiS;(y5-;J@I2_%lkLe9RbV>iDmC&vi>cI1= zQ_RhIdZW`YxN!_>{lAJNNq<8j1^QZQYyF?X(+F`xE*a78fLi~jRFr1svU=-1>ySW< zdI10*duS-?1pteYT*s{fjN*?TtKyq`a<8-6`;!d-Dy%?Xx=jS={PDlSEVAN$X|^37)(TJS=J-v{yIeO>a!~a1TP3t-xj2C#mHwOoif=Q1M0pJpGPL@-LB7i` ze=3eVOx=h??&4$Tr1LCOrmtK2Lr0!t7f`m9kOS20`dc_KU}?nVWNOm;bIaW!O_D)c zvGa#t_P=^o7{4|FSIZyIW)ZbCh7X@_$}$Kb3U0C;quh`4x*z=t0GtO9+UDLe#asMR z_FCK$_9tqK(_i$kYmz+i06Egr;s7r;k|txmbyuqCrBS!>p{B#SNE#NaINTS1tRyK~ z+%wa@9M$Agi2G{S5F4Q_i12(7@CZVpT0QU1r>_90>**Mn zTyJXjK)(XOx5>O}lj=(BCPHjTs(o|W>%2M!#{%hU!t5hQyvd&40FBtaafu6Db*hBP zb<($whl(hle0m1=6$xCg%lBp=n>#fw2C2KePA#=w?W z5UbrSo(IiDjrs+Nl!ePhJ5)DC4s3 zz3*8)f{F>zpn1!nUDD?iu6h?nJ!<(siCeJEF6>-RO2s1pYWY8DYJ2%#)2@x9m3nkC z=sf<-`3+TB|0FNVp*=l}*%h4bTg(3Z1tP(;5BZ;*O;*yeJ(z*}A|UU^ z=ml*#pm)%ipJUkF=(l`Uk3E_voDXOu*Fgf4yFuRDV@B}e$I~%1P1AQ9`t<)oOP}xP89ydANRsSlKnN zqGLc$e=%=ySPTwRo1?9S05cX&8h`zRyNq`>?}pj&!52NDM-6@5z%S#kuLQH)$(Aq9 zW_UmVm%1b-Uva39vWu-&d%B`>ewhbmU4OuYpj+t1yI9?)d9`%-)+`>Y`8-Erj%I>i zTgXWiE$1whT$*_0JN_hr9D38)VZ&y!W*qebfCAeU)C&MCA4_N0hXJ6?B#N9K{cq|H zgiuG;<+VD6I$yT7&OCUqtgNZH`q{coXJ8$f)^6*l?HvGIPwg@AC6-K2eStkHc9fJ9 zAGZKjDu)QA&vd5mod4hP|9AZVo$Eiw|L6Hz=Repvxpw$J|BL=V&(p8{f1d60|HerF z|3GV@FzWn2rESmr;A6D^4+3`p3`+w6X#ZcHT1FlG9I!ThIxLK-Pbqf4QjxuYh-~TU z`71BxUdzPH{a zpn3nRt|`P2Jo13Q6K>q;?U#TRS)yM8w&b#GG~*$6N29|#oN;ZiQdsox%DZ)uqwhuq z5D3mG2o&gXp@(1J?G+yXkBZeL$uEe8l^}>t^8MydzhWw}>Ej5W>LdxJl(iA1?yMUAC z;vuTXOBBf}jS3sFmB^@V1WTUs+_YYKTzbX3Y~6Ue2cnHpc>&?!OO06G5HlV}sO9R9 zBN~#TP2>`0)Qs@GZo1SVpa?3eN-L_PLf#vI6$Iy6)$DS8>`IkLb}Qnlc<%hD_ZqV( z_RU8mFFYz?ek3Pbd3fT2%wLo@g<)Y*iZ>|SJtw}&lz#5^fguvO>X6t|=|e)~e!K81 z08AFgd9=KjSE^V2X;U9#s_^l!2{K*- zx-q`ACyMc*6gdg<*P@qZHlH1_p?oQAq|?PDHYQbA%%!QQYP%9dN3(+4KzX9qKkkHs zqT@K(Mb^WjVAEr!43cCIsvmZCw`S8|n#Ton_4jvF5G;}wWoZ%a<%8I@mMz1}0_l(qiEfuH8Ym||c6(c3Z+0s_M3 zvjPy2{8NW~P7qFGHOa|Za!GQ^kKFX9c1D;qd6u4FYUw&FY;3dIGiQ6`^fmXX`}GnI z7xev?6K&w$Rn3Jtu}m{%9^-V4gl$W6ou^aI&Iyu6wCBkOX05x)Mp8vxc!AZG_K2VP zjrnLtIm!5;qCnx~oOwHf?8)Kh^D_X-7PJpBTG0li8>oVdo#G^@z8Q~AlEC#MV?7`xRkh2N=3 zZ2(r3yIjfq%D4ID%FTyVUWr))eTOsps^;ar)AuZM6GaE=+s@<5 zh8rv#>UgTmD~Q2*V3!z$S5+&1q;8CSs89WC_nwFRMbMs?_ea9S@9vpag<}fY*S~lw zd7&%_T%2jyY;YaZJb(GItI3emk~>Fzoe}@)K?@>!Io33$(A@T2cqyPeRYrjpn<%hv(~i9(+0%fkzCRuljIROVgBan8|_E{ z7!$SVB6I&3;oI_KE(IOp_Jx~-%9iY*I{wuqWW+=cG4cwL?rLJdb(-v>}Z65TM|~bw?s$ZrjlQ{Q~>#l zuCB-895R(EB#fR_%(7gw)K-fL*%mTP$NR-SEW#hWKBN#7k#0s>ljN3(9~=o_F`B|t z@R6v+ZiGxL9vHy9EZM7JKHi>?qu%Rstb1-6i2yI;FYCs5$ae%OgynWo1x=K{vM?-q zSr<)UE!0-nT606bOpkLv5&_0xhxQU?M7dkGxLyLqrgO$PHv?O(7uqLNo{wd}61D7b zdM(=6thj=K-(+h+XwW@CiMfZXkwn#tUgt@Ui~2*FlE^eagFQ5cfnjEq7)rR~58f={ z@2}k*${?=N4pG2srwcJ4CS_}p1BB|(5*v3jrN_|`hf=6yM-VXf8D;b|R2gotLYMkx zb{RiF_^PQP0tc*azCyHA+q^=UwZ#?kbX2s<}a!V!cjCC7{e|u(gmCCbHzQD`+}Hf-jWF0 z)_+%GlJ9Q96w2BOetImO32Yz{e%`>M%T>Z`V;x0zhU#k!jMXoE9)g_r5{#M>$~%pB z+2arjOI3}*uI`W4IS{kpK~v8alVS?;V!_B<48x%zG8uV0@n&LiR;G(`Ektr(U`0px zbz`3A4T3sPPCW@EI(>k$W#q=);>u&uwMnU|O}i^Fl4*c@6)c<>S6>DPoQ_M~cYvTQ zvgM{MN;a)!x)B zH9}lIh;7S>@-`#}>>lT)CH9Qt2N$s2H)~2cl*mhQdf@8)WcE;6hj+Sbg&x-0t7*ef zS-m(8G73F5xHp9j<-t|>fEP6y1DgBD%88D1iNfSl@`A-bU_fi(#BMz=XTiRDWq)~3 zSwXD8drqzh-5YCbh6zqHuAqUL)8POS(bxuUOUcy{zDCv?K*Rmriwhyy!fcd54A~00 zu+pKX`V24nF80(wf#jSiJIqfZV7)I88&QIXP^y275V`8IiJut~of>-X`FJYdo>#7U z^MrIv3jJY@KKGXlI-Aygrkld)GulX()VW#!VL+b0!$d~~!LQslXNky(t9QS7$}+0G z34${9ZzDiMBmy+OC`*hNktVx{mo(vu4I)2_6PL=Cs&UUmD<@4yK8%M%8E@)kxfy(e zNIgdovR3)dyLtJvp?Sa)$G7(cBGdNt2fQXVCIf{7oXK?<_7C`~X<4@7ZMu<*258=7 z36K@PE?LH)X=ZDG7cR3BW|-NRb;>tQt04qh+Wqhz*&1ZTx}U7;p3Y*y`KVLs#y2d| z3NOdr?X6duH9XXF2D8=gFl&H!aITNqBN8gScDva~?BM|@LF1*P=qyTy z_qv|ng{%H%&9f3XnU-m*-u5g9qR-zjZD6s=R+XC9hv|H>^gJc688rRG1EYpJ{c442 zxy={_QEOv`3pt$z(mYR9B?!3R&Sq5YIy@TLw*vup@ZS#p+rfW3*Z)oY$M@IX|IN$! z75?L4-@$)>3;(_A{Tl!AZQ(yXI~4v~vfRW=*+??a12xYpSXNj2&`lNG0f(^kZ*KrL zrqH1`0B25zg)#R%RtS8c&nvtMm~^GNIx_b<*(y?6GAv-c(z+`FpINl3PfI&@&`v9t z);eK!@w8F#)NrMKy^~s?R;-$s&euet%~i8Obt( z;pyQgYtM2XK!vqH+eVi zdfQY;I=(jaF=yW1Vag*tMhrEQCIZzfw3~Ld)qPlzBta4hAg&0VN5pbD*WDF4BV`q@ zo13P;Gl4eu6}hcXj(C??B(h)%HX@Gqkk1V+3seRnDxCZ35lSWzb6}zw$54E)yey>D z=3|T5=t{)%286p;RV_k42;p(83^9m1pvs`jQBO6*EK89*%yHn!UV$47fkz+Z>PTYK ztys~~ht%DV!pN@`Rfca0Cvw&<>_Wt;1r4fFE`;2Sxaxdm$F1N;uG4M%TfWzMJ_~#H%DBrnh)GVQR@z_BB&STRTM>NUI+3mXL@~ zvAHVwoueF6ZiT&a*c%-;Ic~;yvZ?lB|A|>lbG|#S(#D%{WQT8zOK6qv1Z+YuhzQLY4)^4 z_|-p;5AP>i^i#xN4Dp#uZ&g#Q*V8Fj#SYh&N|A%|D%D%K3Ld^bx*%ZK?eTIgJy;OK zs65Yd{6Xf!@nfxlslggK>6+v#x2ekXq$TGBAT@n_IP>_Fbm|lZMEG+T+WBscUSOpw zn;5+^DP&kMtk`|8o5fGrPjB~7(jlu7QEwJt`|Mfm5k1UkrX9Iy%c=bTST&K9Yzcz z=2pHkDE!<}qwcJ;R4kh*`Sbh7yv53bb4M}DW4N1{C#{#5UM|GaH>ZPq1+sFS3L`)Y z!|FwbSt$jxF%E1x*ZV``yWfuWxZg!IxD+OIk{bhFKYmSX8;RhxSY6a|RO#}*Q+W3D zsgsT(r*)=m{h1?1$hzKGmCc%x8S7&i1IY*TsKoxw>jyvsxlRydrp zZ#?K3Ic^$VaP}1LQ^XM;ld>z1Rf`OTSe}JUib2F+@;N>Qx&Y;4k_>xUlQqaJRTNfr zkI4pTX4~T>__5yP(YX2WL7eqOKqGdQdIq_6fI?OPuxawlsnIhdRZUq=8j=g9oe%NM zaw2eIxKG794P}+q(8Hha9wcUp7mX|Xuoy-YSn11a_pz_A(?Y8OMc_H`?{@d~YcJSX0{`)@uzR`#!zCcn8d4-mY^7jA6j?xr-T+ zm*w2I0pPgHZXRQAIW3lTS3dVEi?et1Tynj$Y`Uv@@J?g=PkUDa57qkqZ)p)xNoi%W zG?p>;HItBJugH>R3^N$U%ruKNiIiK~5ZXk0NfD(5Dbl9UiYQWOAxTJ<`JZ!Uh8c$H z-uvtK`~2^m`?;BO-hF$X=li_R`@HWaru^YOUFYR@!zDIw`x&j{eSDYU4yhfPWBbu0 zX;tl=J7G)m+PeDgc@)1c+;)H9a%TCSN!L*WmuqSQBp8hSotLMfttw|RHfL2UG(_Y* z6B_TtAkLYD9LU7=!=rqJe7^2JWFa?W`%G!MT$>7$Ib&PQTe# zwA%C46--0!`h-2Sy$Lf4I;}2^Ti77)sbIJIq5@t$19O^geyZQ(@Z)u7UUvI_2&g`+ zdGRD^))AB5Y$vDZ(G6edt1(vhHhGKPC*`|7&Jdk<=1XXmQoftjMAjWp8ZI--RH&<+ z?k_KqO|)({)U3+#Yx1n#KI@d^>1RgeO9WySJ7=fkRyiP}43FNgdYx?7HZ4SZkzc~L zr86&8XY~qqTv%Idn%bW}&ZYFE&(bUT_i^GK<6655TD_mREHs%!P_;DbNqGCd$x|jr z-8A!AMzs4K`v-3KE*C~xuVhr+@3NS(SnP@9ZIyPC*Q8gAFU+%B(1AF1p@?!oky21` z?5XFhE{^U$NTvHesmqw#R~wW*(Bj5=asyW!MzM|Lq%S}RHzbdP=Q=1llU z&I8~dqZPw~fBKl9+l*qOT?ZqrwT$6^bAGzGrP()u2T?}_o}wohFE;YAC*Qr?m(r&Gow88yIHPs#~%OL7gZS&Ng6P4=@YAa z>pt~dalOcl@uB;|Qf{2RDdX+uw^7Q@+=j==C{{Zngs?MEKGbOye`1- zo7&U>i4dXuU}AaMHpj$r{lg;n#D&i%KNXLD=ola)lfAFZ<+Jv#$iAo;b0aTpteT|y zfvJAMH=1MJzOKLXP_6oej9^SzvWszUV%zre!p_}f`)I+5 zd9~9j7PZ8?&ka(N%dCjmpuUJKiL|@%F+`|JC1rLcM&RJLLX~udWo@cog;Oi8ZF=*e zwr0_d9Ua1_brs5CMVh+;DZyA&pTAo7w@sxpz&oMf(Q+u)^ABuu+%~_;-gi>$ny#LK z?gmEhq8E*+Pofiy`rm$QEncWYd*I`Eahy!oO;q0_iHl1Z`mc&hopTK0kv`>XmWG97 z6V_?_W`D5I5q8cMM`CLd74>`13x*Y}tzK9iVoFLnm)sk%LFcS<_`t-5S1s;ExSj~F zB=pN!-PnG!C8d{hqQts$HYb)PRT(R|dy4GN?L|7YU$Xu@E3jQ;iD%cB=^vL(rhVV< zBT&7WzQ5$qJg4lhPx9?(6KYH%jvTt<5|lMCP{!yQU`%NXmkb*qO`bJS`RQFR!#k(( zTiuK643T~IS3S2y7og~4Oi>?T+vfDNY31X*WaTn;dB|i7D@;sL4dA> zLLb77SEI-Vuzo|kmJ znRm=A_xr4-0R0!;oA8hQAyrtwP1Du*%?22a`|I_iz5x^SY;9;>hCMi+ljc z$A5hJkB|TO_-_>a2TX(CcK?&A+Q|EV`RD(B8~&U3b~yfH4L*SXIPU+IpG$Dg-cSuW z0C!8$7I}OkOpxgS9N{OOcv~d9?z+jEFPE;;R_4>+G*32qsvwZ$nfCan@Qr|j@g4Yqi@?|9IA zl)byi@oO959vwXr_OtiT7cnbWW4sLP{nSJ1e$W@zn)Y$?;kwRHcabn?_irY1_s?!d zBO+nPshx`~7~zVlOC*r1A4NWff?^HtCA0@2@Q0R4rYK#_ev5Dp#|-+R(u z{D=uP$JOasbi;>0XN;A(6?I&rj?E(9SAXoZ*AH*m`|7Z@#`CYP<((-t`_D~jPBwK| zcRzb#0(NPNp?ucsqx(y7{tk!q)1LoCZ| zb!8Us=*x4iDqU@wTNO>-y_1j`?;)+)>eVO4(Dtqm7+)H62Ld}8QC>DXH&?fN`9SUCQ+Z`RakT2vYS z_M*uhO0(@Erz|~iUk%$Xk6D(Xx@TreVY$za&jzk&STVlTB^o2j7 zcg2by*(mj1Hub_(jbkob;n7{^@5x%8Ue=+XsP8+?q9Fe4TYKL)moNHvf;!99N>tjd zUoWrFpXxqyQCiHFC!Hz!uF{K7eec;PWbwr}JTac}ix)*-8-x1u*q|9Eua=i7&5cWBlL%66f$%+U=gm+J!-2=q*xHgoRIsy(K` zcYVbw*FR`ZaO+xs=j&l)HTk4J1OSbdnE)`&Fc|=Vxk~F7+fmyx^w3vJ)RF-Jcoiw? zLn_=|xfq+!&UjaE?Zx=q*=v1=af8(9P{^3@p|@}+qkMNzV{_eYvnoa7=%}@yzQ3F9 zU|zUAFGq|l_Vvg-qA zR;^#tYNY;B_k}`ze0O_hW8R&~t~=zF8b$OMA>P07^WJ#)+`O+zrkb-VO76YB`MS4m z+9M?6(Beb4b|$>JJT0i!UMS<}ZmyLgx zR+jGn5`n9>xUx5(GW~Q*-r|<-N?Z`OS*yS~*S(d|{xZX-vq$33u$-`o2OAkXoi~QB ztxf2ZYDLtQ2`Sc=i$2$P!3ZaUHv_-;l;>1ZIs2w#Rf$fwtIU0koseURL{EvLui0R`9N zFeja%6a6}H;lU$>yinJyMNc-rifY>HIuVz%?2!JS>SAujrxeQ$JABE}-!t`$)5{44 zQ_!mdPOeJaruVEuBu;qa2^*F3QB+NWv`e=?!7bZQ+b{*4M6SeLwIf{bpkRk`36iXw0i?$HFJ?TU<~kRjVV`P!Rp10c|>3wFQgr=A7?1YVkjeZvQVeEi49e|-GM$A7%=pW5%+{~L+_wD|U) zzYYJ%^T2;5=vs;CihdvGL15yIOQi*39U2ATu3`8qCD-z!$5!Gpj>U{-d`uz z-rvWd@!F}--k+~-=&qi&+?t^Bh&-R>!be29K!xzNo$gs%<`W_Km;PrU2><5dtmJP$ zd9g=MeA?&sS7o2_P!VDzJqZ4_vf-c2u{RA2Lk&h(Lo5LQA}ai7vESR9NY! z?bcU!x6gJ$xp>(-DZNAsX>qyb7KD_v-KzCbE9W&ikMFrLUr7#I=njwS*>Y@o{>I{4 z0sR2{lZONFZ$AtE*Pgv?`=h_iS4q}98@VDqe`%KxNy6^t2a&?plV7zycplwR>MEY3e!(OD{>hHX zBHFH+)T@Mt+wG=3^-F6`i8K}NOaeFmY*?FrnXjb25RC1&Y#TT8S#{QA%QmmP?XgQI z$4(+-9yr`n|3xtwbIVJAPVmDO#V%5Vn}2xR)i~$s7gRB0 zb8ZcTG=X$_4PxQzX`g3{?|kGjS8)G?l+`zx;BOlM{?doQUjiHau{Qr^3~l}a;4kBJ zE&%>IK=8L;R%PFf2Onm?o^qtXUwy`uL|^w)_LEzUjyRGNcUZ`L5q58$c)cei_nX}H zdj*2)Vs6ftp72$*`&y*q7Ry^tpBg}$e>+EP{yqQDgHrY?+MA5>TfM>zcWF0bje*y# zyR!~fd={SWX?IcJ)xNmsxbI62xRPPcPv_mT+b0)XNwuph%$Cz5pVZ85`gEw}{OMEJ zHK)zQcU%q^ovE%~6#uY%e?Bsv9^Wgyu=;g7&3>1PZ%|+T9f#7uxKlS`o4Z;L1;>7I zzLT?Pzvs=)y$7Nk-kjPMH^VufWA{%+8#_Z%{lKlY3p#qR65+LK1*a^v5AC1V{bEbS zwx^bVED#q^PJUM|_n6!_L-4tozZ16W30)IQ0;BQeX2>wc$;Lq|@iI&ri#RF}z zCE{t0bG~n}^|ihcNdMG2-Q#x8MEgT~!=_{AnJv+C&0aciDVV(hxB?z67w%g2pgd>o z=2zK&j@SUa5=iOmdcEtTep8Opx|kWg^^ybcgkPjYENpx8EW?>-?wI4FkowZVSzCF& zrZfHA!4n7%6`uuFa;q`-^8A}=C*OLNOUT60es+DGD zZV$^IIF`~_<7~98xBBKfA0@&O@yx8PW;NjwXTQCW{w9?7ZR;$xypTWXX#)EF4|1Hk zFz()Jr-YI+3=V{C-{leVPGxUL@JH?Eb#()EhZ%;iua%`;i9RR&sK@zYINR2iR_iu(=jF*QNxET~E7hbw+7sSUP5&v_zjps=KQ*^4xr)U)iRw z)lUeu>-uy;F5$P&anEpZ#yk){aRl>lollC+{7Z=m*Yk9-^VriG zxn%FdWwB?M>ZwXLIHz{yb{j=CoQ{;&Tc{+nMVwQe~6Qy;Sb)DuQZD>7MU`w? z)7p5=dUK0+x5;$*9ac32+-jvY0r{rgm)rJ;b$Tx`58FAdwY`1vy+d=a|M~WT#!+8` zJL7{Um!$=Lb$Tao^38?(Yfsh}hCb7!#B3g*=}UIT6d$|3WZsUKGMY>E|5$@0M^!v> znT@$n^k`{8@7qw=_wPG5C3TcRNLP&WHz>hr|=#jF<47_}Q+HjtYYXueF|Gr+Z z^C2LyA}g1BT%B+}L+`bbv6qqaQ@4seX>;`>+Q0UElqd_`i2WRXG@FrAVg!~IeT?_y`gx29!39!MhQ*jaZ*5OYoUYa#idtX? zM^7AQVq0!gA&v2;<<3x=t>=MxM1LR_DfY$8;!6F(uSq#~v!kX{cd6Tz8*iHbU|Z_q zMYqJld^!tuYYsf^ozvjEzd^1CtCXYX468deW7hS$23c3ybf*HDgVhSLE6x1N31Zc) z8FEw3yvJ^@@CdY%@Uxu$vF2LQ_If>Y-MfY_6yW9(n$noCyJbNZ`P=ZM`l+q9rnV-x zU!7S0%66WkzHGd8k69ElJtdc`S;)~P_mGjH?xU4>qP_yY)V>T_Dal_{d^>AWAg`6ODxk2>dG_<4m zbA;m(n1{LX_{eXI#lGb}Kc{;n&NuG6#_Rdjg0qw__rJUqQP6hXeZrMw#9^Hw$0HphwO~Rqs8ye7{C3<(PsM159hIbG%Jo3L!okOcZ(_lC(WbZRd=KG-@~2S%uiHFFNJ2#r$*E zi+46UY`=EvM%#v^@WbmfHOwiruU-C8lr?{Xy)rXdq7C(quby#dRjrR$AWEIllwH#!m4rAl_k7YS=Xn}eo$47` zjPeVgjD=`OA?@_a^7&hq)@6|PUHlf4bi;FVL0a#UN4C@%4RU>zJ(m0PR*q|X{dC(g z+7$TU+`VYH4W04*_8)dUcI_}^^2==UTxu%>s!uw z>xq7e-CuWMP6lb|Kx#o~HA=7l{lg<~7{0m-HR~86cVuP)16|qkk@1PN=ksQhwQ;xf zC!!nPzeKO}h;u4D{!z$}NP*Yx31UM1|S4Gi1-N`Qv?5MnsO*``f|h_fZqv zBNAV2dcCeW?LvBO=z$`+_KJuvKD*u3uFGnww8d+kJQDNuLEqWhSi>iqZrtvyD0zSO ziB4hay7KB6d2H~j0$oPWz}7Hz-^bm@C(8tSgy@zUo@;J=|7XmB4D+zvi^Co_mOOOy zvb$znlAe3u%msv-lb4Ij;`s1@S)s22t0e<-PdH{>^6s5RY9cx~=I?uP=ac3quaHC? z*PyLw<)WtOdGXu3oi9~AaL!j^aY>H@a-H9Q(f(LFTf5rF)C(@G+&InW*olYX8MGSPmp9i) z<%csYXS)Oz-uJ$|&M(Wwt=8iz`cd=Kr`;DCmK=H_8rY|I_(K@Yx#VF1A}=f0R9nhP zy;&0RR2mUjzg}k`JGzBAZ5-# z#FcIa^}ea1?_Erc-10osCCRU=Ro*1YmFv%pYCgB+>*F4^=JxjXiTf-pF04GZY>Q=_ z>F&OdN1`_i?@Y7W>RI?DF)jf&P+4hxv-6U{p5CmY@o~OotLMo$EA45>u@0OkV&L2~ zeb=X`wR(D3wQcKmEKz%q+>!L?OZlP2+0Dvz$#ptC8}l}2AGx?t3%Ro6S(?kL6i+#g z@D~4|H2K>%m-yOCilRS!IlF6WX8f~EjNFm=N3Y#Gp;lHVzSi{H)`DbAS9p@;z3rIN zGwYPzB$c4|Ii5++x*v;G-nrw9bm!XVvh>`J#V4NYo~uiisixE`l;0M1PCP^lIr#qU z0y9x$d0yY-=htL9)`+B3?ZNM<(Y>X4UsA_4GG%&wjCy})vYCLER-)h^w*yg&-^w{V z-90%Ct|R_#qUtO+k*8Iz2^yD-TL{)Q0X@f`Hj6IXmEATGnW^tkGeKP7htKBCuSu(5 z)H%NjZTa|*kN^1ikB|R&;6IJuhX2$?`hU^lpa1)9`0wO2r4jf~gB$-1{1E?H@W6lO z8pS$sheB3p#x}_=dKrmoS%{kbebf73L}nSSHq%{e*P+)p=30G!a?h9YG-!o@>YX>o z1YonKY*SkrT51xXvGPj!r=3 zFfTk>^>fcaghAlR+YAP&$uO_#QPZk{y1CYqO8Rzhh0vd>)&OI=?$V_jS48~*SK97q zE;i+IrOb?+jx1|G7tM`XaK! zQ`0$QlL?)VbvHSAwq{)Kjx$XSl@bs`+GQV4MXV**NK|z=-4!eMpE3`jKgXir7y$pK z;musi5%bo8@DGpo*$Tox#}Dyo&o9$A&yu6g6RwE3xW?!W(kXgdqo-1x!lT8W1(B%i zrL#~Xx)I4MLJXbl->(uQ-Jd)q_jF+P`I-;KCnkEkW#ybtGfbph@>F~Gb$ONLfzunS zzL(SpOJaNJW-a~xqq3ZIu)`nc?HWVv9kK|(==nAL=n$lJD` zGnFR!JDgrv-=TYQV^qmvtGfZ`Tvk6QI&NC?E^Id=v@X0rkgURx+HsuGW^1I|vik%h zRrfYSYQp^-AC2AA%z@3A5=L^Lp=Fok``+?}y%DN`lR_EO;C5zTB7ENN>yHr3KW<)B zBsVbem9cTNuft?xMT6U#4b#WNFpojWN`J_i*wG&K$V2pOWOAuqL}OTzcfdoZXVp39zYv{F zYi1iu7=I$~zjJf^%y*gdB(r1oUb}lXqjPcI<*tD929Bl3?zW30%-KaXx82n`mdqCT z-t>l1eKz@J!jsfj3-kweZ+&9a(OXA;tkcez9-I;}&^9n>ilMIkEb|J5Z(@#Z1I7Ze z6Z+%NSIWM+a$vlHlEMx1_Isy=KUtSB5>8u;J1%U@X!3ut^KN`nm|*^ZVnJk-f6C?+ z>6EXUTP#}|7;oDo3mZJmosn&iwJ?#folMxLky><{-p+!3N1OXh=8)Cf+z&A}L=>au zYF3B6*gT^q%b=w(^<7AaenZ1z|HrS6-8+0S|4NS^!NT#h(=!JRt)vrMv~n-4U7nq( zeB>jsv@0t|IWOv*R!u~(Mo`YRhT^A715d4&Dm}a5^)-zN;eqnsAMdf>P@cZ!WK5RP z&V?CS_N|^VCjxaWGxlDORVE&M=IpgLVscn5&cP?{M2U^I^XfLQ#1rOQ+>(mi5IIkY zIS(z8FFQBqX5U+?G<%7&@0keQ`O2lWcPuXVKV69Eyjjy-h`oU8K1&EZdty@FvxZ5f z{o9wG^1i-%R^97Efmv_L+wJR)l`0ZH;i^ryInp{J?@(VC87migPIeD^v-+hSZ4%K~ za?9SmH#YRv^e|F0gFp3Fbf813QX?4i^>%$9&`H@L!Z57rX6Vf{KajQThG^`Wz%W15 zyJPOi>G3`(YH}UVjYHq}?d$mV?c4b6AnY?iZz+|HmFy{u@f?TLoUngtiF+e@MPO17 z8m%*{vhvHaPsQ71_id#{J`pTk7b%_^kp0BB{+@bF7F}XhVP8%$^{&WcTy`G}YN`Uq{yzMt#=rmXzli?|c;G)x z-uSPs6*X?sdEdMO*UR`lFIzT(2LSO0&msIw&+NSXZlG&n{88D`!1%6tQZL@TnHKN8 zl_ag#vBP(E`AUly;Xze;p$OWzghwmg!xC0s4%O2AAo9In;L4J@wl~h}MAyg|=bO?N3VFkf%XxO;k~sc_*e17mr?ZPT43I;T&n zoo>{Rn6fBIcgfkd4cPWmWq0;0kKKWI2REB_@R43u`0NW$yXQx3?U=oFP20QB(EBnk z)SuhMtgx}WEW1eAEkID@!$Ip43RC>3dHAg3*cCI!ecx@f*!S_ISb4fZ%iAS#?sqZ- zYD#+SziPhibDQ1Vk6nMl1fPwRlC8>ranJgtgA{V1s{iH`>csvtdu!yAHkDaL=cS$b z5aaKjzohxz_TwV6ZJlcsC!t;j&wn_(tmcrzl8THV)Ot+L@%^6#mL7Y8FRXbfX*3?8 zzOB9$Z+E9g{%MhovFQUDqZ2ET2Um2={N5n6CiGxZ$@_q1Zz#^!zR&%(gIs>8WlNlg zv}&j7#;6E4b*CKT)=it>Q$*Gvp0Bnb>Rg)zM_A0ixb1rPH&WNj%Gf`;zc2NHGU|3H4_a;>g#D6A|(;@sPaU?)XmXxl^=$Xu z?NMC{gl*B$E`H{P$G;!lW0pnp7`HEqG~VK1O?T^1Sd1jF3ErWG) zLP)xlFhkKvSGNmRxAsNCg?(3?mb16;LP`i5OuNQdupfzC)_m0CTxfs(;;$v4^kUq$ zh&dt%?JD7ugmdT zal@o*&l-u4#GG{>!+h$R?o+kGuST2-7j_Z3itK{6{VH^%80GE-gw#U=Uz;l0BXswD z{aDAC^d@pABj(1(zK)1qot58i9!XyrWpgt**t=U|o|EjmWWpusdoMJ^>z&QV*=)C6 z5kMv>4y4{%D+F_!{#^(CD9~#5#afg4&g5zlao7C|G#wI;oto<^HjM#~=>9fKLij!< zKs?YrBs$7an_cyok}rh*&Xmr2v9q__UibcP2h5 zd({n5%hT%t__vP*|E9?0+;-)Je`@7Nmu)GvH{0D;XR_@NiFuLGuAhpGqt8HPCB1~X z>nE#l@{v_N;&!$Jti;DZoVn|F{{8s$Lkqs_TOoq1ozqkrUh3SV>b0#Uq`140ad1On zby29iwc}3V#~ZZXO|n1bbnoecUVY{1C9=Vjciu04Z7Eaecki>JbH9*-*!3gn2aRVh z{%*B;?Q-I`u({rKA6+dR5aoMt=MvXjwo5dvO;QJWezh5h}MC3|r3qzn;GCQ^#Ka(G;P- zK>s#FYW$r$cgj~zug99UE1FBc?h2Ll47@J1VOQ$mlzP(#mEQ;n8Nt3LQ*J$9-w>_3`gU2c^v}myIf!&<_%;q`WCH%_8 z8yUiZ&u4u-wdTA7Vp7Us!7%fg&!)K)28Z2zqh-Dhap1yfyQ!fLVe?x>0R*^qNd++w zm5a%dJ@a|P(o;FrX?1myMpF^$qOV(VCzD*1p5An@GQDL2AwY*-5CMvX9^6~<{+#VG zit~$Vjn8v2sXo~f>!P;Zcv|vp$^gB@$}q-6b+Uy(l&;+1rr3cXBOUyH4;j#@CQ*Fgl(yy!Quu zIy^mez2)=O%O)z8`gSZNB2-SCww$m)q0KcAkE>l#ZJC%t)h)g7to;7h8zIN)S~WY=a;CncIXHw0b6d?*N1V_f23Uz!(q#zKbGluqd)nJI6+V z@H6rB7`w3a5(8Jis~Yoa%61oH&Py8yE{Ujb7dtS+Z%X3hKTAu-mlqEh-nk~MP;s@Q z?zFohNf33xxsL4IAhk{)R-`NHQ`_>who;MtK4)|!s|UpiJ(4-Ia7$^eHpc%${}gkJ zPSY;;C-qwe&Ys9h)twYC8n$xzQy<}`HxrNDT;L#}l1v@A-;H?q;=qOU93QvFms#6$ zm7|f-OvBH`6=*F3>@)>>oTKwc>p)U#jcdsHB)3m>__`toliZyJo=riBNnan{VdOZf z#;+2pV{3P zC(GnwLaIs)A9Z}8-72wue!Rde0fi4)-|?(!@A*|1($9JOAuNUc3I1pQ#G+k{D_oC$BSd9yUhv8RacBlx{#)z=e?&U`z(R@izPX7juyPM9+< zN<=MNiAhch;Sw&j+Glfarw@pBT`A2dleUaaA!HwYbw|-MK`0NgCC|zG%uDZsiAYjT zSpY5}+yN1oU9pBi)?Sx%b$24M_skNf51TKyMcnf&{p48P;$SHfc(z0&sdrYm&BM_E zV3WY^=Atbi02Iytbej_Z_HY6~FQ0Xv8INf4Jq()X0E2p^jX_-;uZfA=v~advYuD4z z3bO)Nm~6SemqinY*{68oUeCC#*M*YJXQRGEOe-1h>MN)|cYDG8!W}J$j>79i#q*hs zk+m_i%vD92$Df*!bZNZ8(i-fkM>i61qL`1!wEOoK?e?u|DB9(5z2)+u#A(#orH3yY z*bWrl>v*y7kjROEv+IoxWKMb+^+8-||9da=S%IT}errox9iqG8Orzt%x$bgj(Zv=; zI`(wCyG61~?CfX7%^z^zTHWo8lvCQOe9G_K4wvnPs+nun&PbHFw{G5CJJXM6GR$8P znyv3=tY25CVwU~(hF#oFiEKr^_g}Pg9QAfBa6KFpGA)B{)^uW?{sq{Rw9BiPIqfST zUKzjS$c<^Z@14lqll=S?XZ#`L(z$PsWp>scYpHFdITgYlir40yzHoK1kKY!j>I1ni zSP1a`fC@R}nNh&ZRa1Zbh;A($*Cu(r|F1&8{|Wr3p*a%&sq^vQZ^3`ML-1r>zML03QYZ+wdRrMdd%S{&jS8MqmHKf2*l$XleZc)8eoH|MnbqfqfAA{q@t!K5{i>eBqh^8fJR^M5}7AH;u}8k)L#I(+{BFX#VW1pGh6`hSA| zt7)sNs}EcM8d|D+|DV5S{Fyx8iy9~*#QN<=B@qW-NYuff)DYk|OJ2ZWu@oGY$`%>$ zWHjpwD+U=u^CjRt+2N~zS5^p+3;QdT2Cy6o0{ly&(?F?%T^j=4%O868hN00!OaKm) zLQpU`8XbVa10o4rUqlE7JUpZq^Hs}%HxVHmfr{gdh2$L#{LhPk0h;1SArXCyR+(Gk zLgY<@y>Mh2ApIz?vLIu?rVT|lUuDE>uYCv^ClA#Zxe7zU1c2}CL}0}M;R$ZO3eAcQ z2Xg>}usCm+7l{bSSTuPmj^K@e1!4$v90Di^$OHlNq6M>a0UsJAgyRi-C^#COLgeUp zc_;&gp;$Pm4a1UwWg)#Wc!In)JPb+^4#QJnL=p|G03Hi8EKn0hqJV$3VXUm-3Y^tO zqp36s8Vyxj0j9hZ7(NO|DXfYCjfQbHT103WvzL4*Bsy8%7e^qY;3ih)FmI?Za4=v% z=fe>&JTZ{uk3$FHX}-X$Kzoq_0x(1@%ANwXj+F}u8XbAKvNDE@SN6w+00}9$^>jQ1 zhefTx0Ar5;D~q8MXeb*J5qM*kI8+i07-#rUhTs4LWf5t(U|=Rf!$ZK!1H*uT0VSY; z64>Jbd_&@hR5}HRCVBedylC=RycbPjC=mGS0%upt4VVBvI2syg0@}+L=jD%PWhc+h zmYEQk>0nxB^Aw5pqUDcq&&D(k>I<$0xL__h?1yCFb&d=geS^_5fCsqYf&*L$cQoqvuBsb?hCFe z47Q(L04^9$rBUVCF*w%&I4NKxGLASH3RHpuSAb!tFz;bg9h_JMJP`*>s1aeT62V{| zAsmecWAN-)Rya5^!`eRDKN!2^jns3O;FV3j(ww2`d&DnGQQFccqp01l`E zoW~*s*E0e}1IQ&%G*~X!fuP<&e%N~Z!Q^4}1CzL;SdAODC;%!11To_v2_%v~K!M=8 zNa#QKP)`EUT;PB>K%-N^b&G#Mb;)y=!F-L@z*YL>0rWt!^95ECk5KABC zOrpau1PXw{LSVj_KpYH195zre>9FCMhD!qzluE;4!8snr?v-#P49;s(d&A9XzzQM| zV4gS_31B@z6g-WFBQjT27&}imf@fl`Ir4)Q88pg@M1m#ah7As9ci7;92Ad1aXDZMU zSQw4O(Kh%fbdHt53V^jSlR#?_M)SoDW+-n;p^zy0urP3(rJ3U_?G~=U{t^K2DvS>f z_%yN$hBu!%T|r3+4Fu6}6nXGBxRh`NER{WVnez^05A4|i5+z710ErS5SD$Ozu*1OI zMsR$p3m6P?6fl_i!ZAGwtn^54Jg^MmNMH~ap1mGLIbM>MWvDnz={Loj*JAv zf#SNTxBq?S2bJTIk|`Q+kaQr6G0FLm%*C9!JVY)w zIp-~WnUDf7fCLJPxFF2}Nf(Ud%~}WGngwJp3dV~zrdS3DCXiIdz0$ZvqLES$(Ce z7o0n+4*Fl4q@Ea>moIak0&^0~1Hq~)`!AZ=cL9JFf9b>LF)MR4t2h+wr>5zk6y(jF zJA|3R{)Wi$u)If?7ghj^oigGO#da%fi{EGLtI6U^2`|FpyLLbb%=xd65Y803wyMPMo!H zfkQ$O1wx8m)Ig?;M3HA@$|-AsGmA;+m|4gJe-uVa2|P1rWlIW@cL7<2zZ)cq_=AJY zJulow6bXnEcR@h23>gqeu~3?yYz!!K zAhGlSvOIULzzCC3!-irM(Q_?vXl*e201zy>`Xu}(rztoxcrrLNEaJf_Ba$H`g9PXo zo;(b5fD#&>42ox}yx<1AD?!lm15P=?m~= zZrTQ|Of;vG0a%TrXpeYA9?F7qfKc zx|Rp)!^%QIpSwOFJpzMAW(Nf$pg>?aSYyVc`pgL2w2?E5VZ@PL1$O>}@4QOjD10cU0#DsMcdf&?YQSAcI2R6M zPShdIq6};}{$PF?Nmn_vX*{9~lFuP>Nd!WvC}lOy1YEQa{dZ`e3~X%bCo{S>n~QFY z3BpDS-)zRlt%VtLZI5Jw3JRks3I}yn^Z-vB1>_`P@8GC3Dl7nl#j&+XUL-n^2I`VH zNE4ol9VO~Z>o1$`hoqgj;79t^Z)9qT73JT-|6t!qY%`8BW3Z02Lkj1xTn^925Z*3n?LxAjJ7T6i%e!i6Bs5#^Al%!x4?0kO1@- zjn4W_BKi!zV5m?%NE9Co5l@A*wBS3L1WbGaR3tF|;HEM=CvO7AXVijm0VF>>ni+-t zMknG2tB;{j@Hh&yLPQ@r#s^1*zSAiH*ztn;24pwrPyjhVFTNBUw1vRziBZdCZOeyq zTOu*fDX=W4hUrz}c3d!F4m1V7}xrNcP$88k4%uuRODIA2uV$T~?yAEnPc-DtY9rBaiGgdeK zsDF4(5>7}4O%yzNY)ipG3XSAQI;fc9K#+sNywxCqd&PUG1}yo=`2sq-q1H5FXg z$~AB&3yxr!tc*wo3^AERq=E)7Y$*xp1F#)tg}sq%nI$|7$~;^e)U81b1;xo-SXRPR z6lWDUITge!A$|^(#4Hra@%aZ_fHVDYD>&XnW?1XE`d@#j3ZSwY+*4w+bA8UT!g*!M z)>lL6xg-35Z2e@Fn7e#`yFy$;%hlSEaX5_(VLV4=L@`6e5E561QAFww=^qeh6ygVl zFe%?ZK`wulG%~j=sR}RynEH=c2HY5EI?M`cI$I-6g$0l(0G;v05MgQ@3>Yzg7&=3l25?rwbd_ zUB6No_NUs!oiCq^^5s9i{KxnI#ee?)$bbK!5x@`RKXo;&;qsrBx(5IL@82i?jqv}) z`o)$DhvYj@rZZY)ZjGb)lCV^Hb_67+tT#;BgValC0(--X+jx-M@EQ&#fRCzw;y%Ew z0IAN|N*mrH3sYR-$OQo^q!f79@t>{UklEx}_3XW+eJ!*qEb9Nc35?l=l{Rej&(L$M@Y9jkL&?cAR4Gtgb19mat zZajP8M;5(<)B*g=H|^!i|9t=N|9kTP2>5Vh(_ff zT>ro3|6Nm6Q-|;Wov;7lkN@w~|FG}>;Ol?*^dF-Ct1Qiw&23iL{zvsc>RRfWI>Yrp zYW({jf6rr$qhX*K;J|eE4!}F4nuw4Mfa>&FC{#pfFbtX|B0_fbfB+07M4xpe1l;ul z-JO7jRiM!o3-X$;f|=n+1QN?LjL|A{5uuf&0Gu)z;{#;QK5OBFr}@%7!IQmg{UaI5 zRYb^$4*aF)!z@Td3}oSDNuv7T@azC(9Dpkc`Y=Cc@Up=J8zMqhcrP3PZ}nl;=Jp~& z%W+gM3Z7{r2$KOSMFXAa!R&y6r{S9saLND$Xx(t@(CeLoi;3owh zEl09Dh2-rG+RS2L-Wbp+kT2fHmk^?i2?Sb#@gy*v)qyT4h!nh+FNZ-Jj>_~VW5vo8 z0W$%ud5Iwipt9f*3Ya;Oh9eO0J~*Nm4l?5;(gSc55}gVo0ad0z$Mazr8jXVYqywlN z$*bKqBpMDDLZZWnIM9-pIOsbMs1nA@3pA`_dDKI~>`9=j1)wG1c?c5I+)hnPRdwh% zGzKU$1m;b_frh|1(9tj6hX|vSK@;H-y`};ll%dinQ0tJsv;YDUX6`*y8L+2-Rs@3H zbZ|pifg=LMp}@!#9MH2AASRgrRGeoKWS~=lk!R-4tNjjmDxL-dvvXNa@`BFIL-iRN zWh}{y%5gME0jdCGg9uVg(8~*0eO`IX$N)nGp1C1Fad<`X@bKVpdVnWuKn{+;<5}#L z$TKEa8EZiwhP=aMAmd4J>aQh3!)3xe`NJ{Iepo9W`r_erV>*CldO~7FVZL$H1!e-y zS;)&J`)mhzk{*0ztrN(#E)uo|2Mi|==m9E{JeX?$8gJIjmhY?kvghKHBC+U-|6BtBTPF-NfHJfdDDl%jqJ45v~{?S zlyM(+167>EgPG^oKqHC(FVKtvuA!z1DuDxt(3`5NCin&&&I4Wn&WH~s5#iz7xqznt zfzTBg3P6IXfp`J|H>@1)^Ls#C8>7);u*eV9QAW<)e%l7xV-O z#+Dr;7MVb&Uqr&^hEP@LR3w?meaMofa_3qu+~Lp}Q+=j053Z1*la`P= zpWWxCL>~g4>dTz2?5<^h40jur1?%)EAmafdp0<`AcktkWQhk`Ro;FfLTUVPa0@vwM zpcoTC6!9X#Rsj+g5m*7fcrt9cu@Q{5TwsoP3J%;6Ra04A*IrFWUrSqGOAo28sjZ`>rL3x@uc|s$UO?rrRImw<*SOK+ zvFAUFQmUBv0{{Au#>yIy%BeVrfCAM3Y7rj(Qyia-BuGR8C<5b{heH00m6geeUENN(*6Z7GhA{Tm86ao@pxmLVe$Sx;u(?Q5dP6Y>S(Er6_*PP=^%C0)yEPo ze~xDipHOTl^aBzxh>CtH9(&dPAo5>BMSl$={Vl}wH}TV7LsNedTm3xJ8a^6-0eAfZ z3e(kBRoB5_O}t*5AoWlNjZ7AhK7dLukdi~{|FPeHTg9rZtH~u zD9C??eH*R)74~gJ2RCR4Kd1{Y)@`)**ID;qX~f^l!a2(Q>#W>p?O$N#MJETj`mZx{ zQ*9PAH~Krw97F#(X3mz&|HqlRvW5myQ&00Jxw)FA22x#3Rr43vxe0+p#{ykP!685o zOGrLb=QH)c%+!rc?fwa_u4k{RH^|ks)pd20)Kvc(SO598^Ca9Vb|F|I=s^D0`8rT# zh_Ac-EyW7VXwa}?#i;r@qrodd*p5(H9*&p}UjV%788Ud0ARhGcjwjN9|CzqdT-eG5 zRtck;3V`mAAR7hHKtqMCXHXvD-iNFHkmsWx)EXcwRM4p#3{bSw4c5>O>NS`N zKqr2-dd!Yi21(1Pb+c!BdF5d}LxNH0L}qV}7Khu^0Wz}DfN>u@{F(7(DV$8K%y~tC zY$5bv9;{d{wyW&TZEft_U?#TK)kvZ6T8(ZXZt`gIB_M!0b(( z>|w)h>ExA<3G>o|VHrVpgn4j%0!>JUf5FqZzXae$RU!^`2Fx@V6F?^5sG~#$O;PYP zJg`PXaZG*Qi0X6oIa}EXB!U^RqAndJE_jn^044+&ekuua(aX_iLy0|@x9%`KQ9>9VB1?ZZ>%seQn64i|KMt&0u|Sf=>N#-7!fVVckbb^mF!m)(;Po7wSYoUi8-o*_ z<;3#`ML|~&Li2H`+i*~^%!{;GF@Wj9d|&#r<+ARn9lYZZx?2v&>YuI;Y}nn1P(i=g zX&&tO9x&!zEFeW^-@pR8tpl%4WM7cSERAbbtODd;uylRcS{q9nTSpraArtWME*{90 zq7P%)M#A*O|pLRt0*KNV4VhlbIFP&u7e3!){($3jbBcH z)ZwzsR5k(ca3Ru^!TZ1TVW3+^l>iLI9}BEy{>ef9`47JRH~;(x|M|V=KkRJRt}!vS zL(+n2|GD!Ys%jdl8pF?j=xC_&&wu=m$I9Hq)W*&f9AY5=5}B_swX+vmV`{YA+7uap z6=I1UpdmaJiUYuNIvMHZ?Zf&-4iUlv(j;r@D61h=kwAYUeL{zQR^$Cyo%d%A-k&vj zf7astS)2D~9p0aHd4Ja9^;w;FE7f_oQk{1z)p@s4op&qMhg!)xCBwe`0vrO?C#KIv zq2br~2#xgXD8$p%kI-lbz=X!23?ss@IPmsea0>uUz!UwcJR{*CDVR*cvnST@2y6$> zJOgPYGMa!3#1Z~3Y2wR&eEE+r|MBHNzWm2a{^Lc-|9SZj6al%{zdAqx`1im4mWRL8 z`TU>H|M~o%&;R-SU*$(2?LVXcQB~C%?*9#RK*s0)zvFT7A`z)10uIgGh@rX(4L`%- zMZoKWce4xesREz>^Z7rY|MQ=J3;*ZT!T!yqF(&`l7>57Vv^6!=`27EOJZxu8LW6hu z3IPn?%b!mI`23&G|M~o%|NM*j|39?N`eXh7aQ~m$S~`6HpTBYZ`3s)U|M~p?U(f$l z^)xipbou=M-^%}oTl@Wk>wirCuQr_jYij85_y2z@{ug1we*S{s^Z(z^|M~Yn{rC7k z&-A9&UgT!15`< z`T|3-ulx`ZatUBv6#_)Va-7?QPQapIgJ(vdxHumlG^j!X;?giaXiqwxfCVBk->{(9 zNfb;KjLBbteE!eJ|NkTSUqf3}T~CLP|Nq_m|DU+>{a5(E8hF@q82{H+=j;D}&-k;P zLt|*Z1iYt+5ac)-ng=uzi9lsLkA}W5Z@#wo#lf(63eJm0qJS=2AYVwJp9dOvX%^F| z#!AyQrT~X$<%(Pd{FO(e!7wyh0ZGPyGR9DDpsxqep9Sb+i77^~vSGcTVDeCgDlq9m zDMMNTNdZ_E8cZv%z|IS(fEVO;h{UY1yaEhM!chkuI|Y#_{vynitMW{j8KBQ9lr(b{ zNV9xRv4kI#G_)$%UpZtRl=LrNe8$cLbT)_P%!t{ZQ9{vPOqUpFS_m1aB$rDJc8+uk z0VVyTtJK($G0?qfDCuDrs&NOSp=;Gp(j%@_8*BvALkLRR+T5P~&1=y22NmTqzV*C0sw%v>Kgz6 literal 0 HcmV?d00001 diff --git a/ipdata.egg-info/PKG-INFO b/ipdata.egg-info/PKG-INFO index 682b947..d6e0f2e 100644 --- a/ipdata.egg-info/PKG-INFO +++ b/ipdata.egg-info/PKG-INFO @@ -22,7 +22,7 @@ Description: # Getting Started ## Usage - 1. Looking Up the Calling IP Address + ### Looking Up the Calling IP Address ``` from ipdata import ipdata @@ -33,7 +33,7 @@ Description: # Getting Started pprint(response) ``` - 2. Looking Up any IP Address + ### Looking Up any IP Address ``` from ipdata import ipdata @@ -61,7 +61,7 @@ Description: # Getting Started 'native': '$', 'plural': 'US dollars', 'symbol': '$'}, - 'emoji_flag': '🇺🇸', + 'emoji_flag': 'рџ‡єрџ‡ё', 'emoji_unicode': 'U+1F1FA U+1F1F8', 'flag': 'https://ipdata.co/flags/us.png', 'ip': '69.78.70.144', @@ -88,7 +88,7 @@ Description: # Getting Started 'offset': '-0500'}} ``` - 3. Getting only one field + ### Getting only one field ``` from ipdata import ipdata @@ -105,7 +105,7 @@ Description: # Getting Started {'organisation': 'Google LLC', 'status': 200} ``` - 4. Getting a number of specific fields + ### Getting a number of specific fields ``` from ipdata import ipdata @@ -125,7 +125,7 @@ Description: # Getting Started 'status': 200} ``` - 5. Bulk Lookups + ### Bulk Lookups ``` from ipdata import ipdata @@ -152,7 +152,7 @@ Description: # Getting Started 'native': '$', 'plural': 'US dollars', 'symbol': '$'}, - 'emoji_flag': '🇺🇸', + 'emoji_flag': 'рџ‡єрџ‡ё', 'emoji_unicode': 'U+1F1FA U+1F1F8', 'flag': 'https://ipdata.co/flags/us.png', 'ip': '8.8.8.8', @@ -189,7 +189,7 @@ Description: # Getting Started 'native': '$', 'plural': 'Australian dollars', 'symbol': 'AU$'}, - 'emoji_flag': '🇦🇺', + 'emoji_flag': '🇦🇺', 'emoji_unicode': 'U+1F1E6 U+1F1FA', 'flag': 'https://ipdata.co/flags/au.png', 'ip': '1.1.1.1', @@ -231,6 +231,55 @@ Description: # Getting Started ``` python3 test_ipdata.py ``` + + ## ipdata CLI + + Usage: `ipdata [OPTIONS] COMMAND [ARGS]...` + + Options: + `--api-key` TEXT IPData API Key + + Commands: + `batch` + `info` + `init` + `me` + + ### ipdata CLI Examples + + #### Initialize with API Key + ``` + ipdata init + ``` + You may also pass `--api-key ` extra param to any command to + specify API Key. + + #### Lookup your own IP address + ``` + ipdata + ``` + or + ``` + ipdata me + ``` + #### Look up an IP address + ``` + ipdata + ``` + #### Look up an I address and filter result by specifying coma separated list of fields + ``` + ipdata --fields ip,country_code + ``` + #### Batch lookup + ``` + ipdata --output + ``` + #### Batch lookup with output to CSV file + ``` + ipdata --output --output-format CSV --fields ip,country_code + ``` + `--fields` option is required in case of CSV output. + Platform: UNKNOWN Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python :: 3 diff --git a/ipdata.egg-info/SOURCES.txt b/ipdata.egg-info/SOURCES.txt index c8f6eb1..3541ed1 100644 --- a/ipdata.egg-info/SOURCES.txt +++ b/ipdata.egg-info/SOURCES.txt @@ -23,9 +23,11 @@ dist/ipdata-2.5.tar.gz ipdata/__init__.py ipdata/cli.py ipdata/ipdata.py +ipdata/test_cli.py ipdata/test_ipdata.py ipdata.egg-info/PKG-INFO ipdata.egg-info/SOURCES.txt ipdata.egg-info/dependency_links.txt +ipdata.egg-info/entry_points.txt ipdata.egg-info/requires.txt ipdata.egg-info/top_level.txt \ No newline at end of file diff --git a/ipdata.egg-info/entry_points.txt b/ipdata.egg-info/entry_points.txt new file mode 100644 index 0000000..72fdd24 --- /dev/null +++ b/ipdata.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +ipdata = ipdata.cli:todo + diff --git a/ipdata.egg-info/requires.txt b/ipdata.egg-info/requires.txt index 454fd7c..9300b51 100644 --- a/ipdata.egg-info/requires.txt +++ b/ipdata.egg-info/requires.txt @@ -1,3 +1,3 @@ requests ipaddress -click \ No newline at end of file +click From b17dd60c13937c0ed3208949ada144bda3ff26ae Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 6 Nov 2020 02:27:43 +0300 Subject: [PATCH 002/100] Fixed workflow file. --- .github/workflows/python-publish.yml | 59 ++++++++++++++++------------ 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4e1ef42..f5bb677 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,31 +1,40 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries +name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI -name: Upload Python Package - -on: - release: - types: [created] +on: push jobs: - deploy: - + build-n-publish: + name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@master + - name: Set up Python 3.7 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + - name: Install pep517 + run: >- + python -m + pip install + pep517 + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + pep517.build + --source + --binary + --out-dir dist/ + . + - name: Publish distribution 📦 to Test PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.test_pypi_password }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* + user: __token__ + password: ${{ secrets.pypi_password }} From 4bf429015e3b14f5db2d5c7d15fbe49ef7e36f34 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 6 Nov 2020 02:31:53 +0300 Subject: [PATCH 003/100] Fixed build system --- .gitignore | 6 + MANIFEST | 6 - MANIFEST.in | 4 + build/lib/ipdata/__init__.py | 3 - build/lib/ipdata/cli.py | 240 ---------------------- build/lib/ipdata/ipdata.py | 102 ---------- build/lib/ipdata/test_cli.py | 20 -- build/lib/ipdata/test_ipdata.py | 32 --- dist/ipdata-1.0.tar.gz | Bin 1422 -> 0 bytes dist/ipdata-1.1.tar.gz | Bin 1770 -> 0 bytes dist/ipdata-1.2.tar.gz | Bin 1807 -> 0 bytes dist/ipdata-1.3.tar.gz | Bin 1421 -> 0 bytes dist/ipdata-1.4.tar.gz | Bin 1086 -> 0 bytes dist/ipdata-1.5.tar.gz | Bin 1422 -> 0 bytes dist/ipdata-1.6.tar.gz | Bin 1628 -> 0 bytes dist/ipdata-1.7.tar.gz | Bin 1631 -> 0 bytes dist/ipdata-1.8.tar.gz | Bin 1787 -> 0 bytes dist/ipdata-1.9.tar.gz | Bin 1788 -> 0 bytes dist/ipdata-2.0.tar.gz | Bin 1793 -> 0 bytes dist/ipdata-2.1.tar.gz | Bin 1785 -> 0 bytes dist/ipdata-2.2.tar.gz | Bin 2004 -> 0 bytes dist/ipdata-2.3.tar.gz | Bin 1995 -> 0 bytes dist/ipdata-2.4.tar.gz | Bin 1993 -> 0 bytes dist/ipdata-2.5.tar.gz | Bin 1996 -> 0 bytes dist/ipdata-2.7.tar.gz | Bin 34664 -> 0 bytes dist/ipdata-2.8-py3-none-any.whl | Bin 5496 -> 0 bytes dist/ipdata-2.8.tar.gz | Bin 34706 -> 0 bytes dist/ipdata-3.0-py3-none-any.whl | Bin 5497 -> 0 bytes dist/ipdata-3.0.tar.gz | Bin 34704 -> 0 bytes dist/ipdata-3.1-py3-none-any.whl | Bin 5460 -> 0 bytes dist/ipdata-3.1.tar.gz | Bin 34647 -> 0 bytes dist/ipdata-3.2-py3-none-any.whl | Bin 5461 -> 0 bytes dist/ipdata-3.2.tar.gz | Bin 34646 -> 0 bytes dist/ipdata-3.3.1-py3-none-any.whl | Bin 8754 -> 0 bytes dist/ipdata-3.3.1.tar.gz | Bin 38693 -> 0 bytes ipdata.egg-info/PKG-INFO | 287 --------------------------- ipdata.egg-info/SOURCES.txt | 33 --- ipdata.egg-info/dependency_links.txt | 1 - ipdata.egg-info/entry_points.txt | 3 - ipdata.egg-info/requires.txt | 3 - ipdata.egg-info/top_level.txt | 1 - pyproject.toml | 3 + 42 files changed, 13 insertions(+), 731 deletions(-) delete mode 100644 MANIFEST create mode 100644 MANIFEST.in delete mode 100644 build/lib/ipdata/__init__.py delete mode 100644 build/lib/ipdata/cli.py delete mode 100644 build/lib/ipdata/ipdata.py delete mode 100644 build/lib/ipdata/test_cli.py delete mode 100644 build/lib/ipdata/test_ipdata.py delete mode 100644 dist/ipdata-1.0.tar.gz delete mode 100644 dist/ipdata-1.1.tar.gz delete mode 100644 dist/ipdata-1.2.tar.gz delete mode 100644 dist/ipdata-1.3.tar.gz delete mode 100644 dist/ipdata-1.4.tar.gz delete mode 100644 dist/ipdata-1.5.tar.gz delete mode 100644 dist/ipdata-1.6.tar.gz delete mode 100644 dist/ipdata-1.7.tar.gz delete mode 100644 dist/ipdata-1.8.tar.gz delete mode 100644 dist/ipdata-1.9.tar.gz delete mode 100644 dist/ipdata-2.0.tar.gz delete mode 100644 dist/ipdata-2.1.tar.gz delete mode 100644 dist/ipdata-2.2.tar.gz delete mode 100644 dist/ipdata-2.3.tar.gz delete mode 100644 dist/ipdata-2.4.tar.gz delete mode 100644 dist/ipdata-2.5.tar.gz delete mode 100644 dist/ipdata-2.7.tar.gz delete mode 100644 dist/ipdata-2.8-py3-none-any.whl delete mode 100644 dist/ipdata-2.8.tar.gz delete mode 100644 dist/ipdata-3.0-py3-none-any.whl delete mode 100644 dist/ipdata-3.0.tar.gz delete mode 100644 dist/ipdata-3.1-py3-none-any.whl delete mode 100644 dist/ipdata-3.1.tar.gz delete mode 100644 dist/ipdata-3.2-py3-none-any.whl delete mode 100644 dist/ipdata-3.2.tar.gz delete mode 100644 dist/ipdata-3.3.1-py3-none-any.whl delete mode 100644 dist/ipdata-3.3.1.tar.gz delete mode 100644 ipdata.egg-info/PKG-INFO delete mode 100644 ipdata.egg-info/SOURCES.txt delete mode 100644 ipdata.egg-info/dependency_links.txt delete mode 100644 ipdata.egg-info/entry_points.txt delete mode 100644 ipdata.egg-info/requires.txt delete mode 100644 ipdata.egg-info/top_level.txt create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index bd21622..a68b1ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ publish.sh .idea/ +v/ +build/ +dist/ +/*.egg-info/ +/*.egg +*.pyc diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 8a8bac1..0000000 --- a/MANIFEST +++ /dev/null @@ -1,6 +0,0 @@ -# file GENERATED by distutils, do NOT edit -README -setup.cfg -setup.py -ipdata/__init__.py -ipdata/ipdata.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cccd231 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include pyproject.toml +include *.md +include LICENSE.txt +recursive-include ipdata * diff --git a/build/lib/ipdata/__init__.py b/build/lib/ipdata/__init__.py deleted file mode 100644 index 3ddbd99..0000000 --- a/build/lib/ipdata/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ipdata import * - -__version__ = "3.2" \ No newline at end of file diff --git a/build/lib/ipdata/cli.py b/build/lib/ipdata/cli.py deleted file mode 100644 index c563a28..0000000 --- a/build/lib/ipdata/cli.py +++ /dev/null @@ -1,240 +0,0 @@ -import csv -import json -import os -import sys -from ipaddress import ip_address -from pathlib import Path -from sys import stderr, stdout - -import click - -if __name__ == '__main__': - from ipdata import IPData -else: - from .ipdata import IPData - - -class WrongAPIKey(Exception): - pass - - -class IPAddressType(click.ParamType): - name = 'IP_Address' - - def convert(self, value, param, ctx): - try: - return ip_address(value) - except: - self.fail(f'{value} is not valid IPv4 or IPv6 address') - - def __str__(self) -> str: - return 'IP Address' - - -@click.group(help='CLI for IPData API', invoke_without_command=True) -@click.option('--api-key', required=False, default=None, help='IPData API Key') -@click.pass_context -def cli(ctx, api_key): - ctx.ensure_object(dict) - ctx.obj['api-key'] = get_and_check_api_key(api_key) - if ctx.invoked_subcommand is None: - print_ip_info(api_key) - else: - pass - - -def get_api_key_path(): - home = str(Path.home()) - return os.path.join(home, '.ipdata') - - -def get_api_key(): - key_path = get_api_key_path() - if os.path.exists(key_path): - with open(key_path, 'r') as f: - for line in f: - if line: - return line - else: - return None - - -def get_and_check_api_key(api_key: str = None) -> str: - if api_key is None: - api_key = get_api_key() - if api_key is None: - print(f'Please specify IPData API Key', file=stderr) - raise WrongAPIKey - return api_key - - -@cli.command() -@click.argument('api-key', required=True, type=str) -def init(api_key): - key_path = get_api_key_path() - - ipdata = IPData(api_key) - res = ipdata.lookup('8.8.8.8') - if res['status'] == 200: - existing_api_key = get_api_key() - if existing_api_key: - print(f'Warning: You already have an IPData API Key "{existing_api_key}" listed in {key_path}. ' - f'It will be overwritten with {api_key}', - file=stderr) - - with open(key_path, 'w') as f: - f.write(api_key) - print(f'New API Key is saved to {key_path}') - else: - print(f'Failed to check the API Key (Error: {res["status"]}): {res["message"]}', - file=stderr) - - -def json_filter(json, fields): - res = dict() - for name in fields: - if name in json: - res[name] = json[name] - elif name.find('.') != -1: - parts = name.split('.') - part = parts[0] if len(parts) > 1 else None - if part and part in json: - sub_value = json_filter(json[part], ('.'.join(parts[1:]), )) - if isinstance(sub_value, dict): - if part not in res: - res[part] = sub_value - else: - res[part] = {**res[part], **sub_value} - else: - res[part] = sub_value - else: - pass - return res - - -@cli.command() -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.pass_context -def me(ctx, fields): - print_ip_info(ctx.obj['api-key'], ip=None, fields=fields.split(',') if fields else None) - - -@cli.command() -@click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) -@click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), - help='Output to file or stdout') -@click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', - help='Format of output') -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.pass_context -def batch(ctx, ip_list, output, output_format, fields): - extract_fields = fields.split(',') if fields else None - - if output_format == 'CSV' and extract_fields is None: - print(f'Output in CSV format is not supported without specification of exactly fields to extract ' - f'because of plain nature of CSV format. Please use JSON format instead.', file=stderr) - return - - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) - - def print_result(res): - result_context['writer'].writerow([res[k] for k in extract_fields]) - - def finish(): - pass - - elif output_format == 'JSON': - result_context['results'] = [] - - def print_result(res): - result_context['results'].append(res) - - def finish(): - json.dump(result_context, fp=output) - - else: - print(f'Unsupported format: {output_format}', file=stderr) - return - - for ip in ip_list: - ip = ip.strip() - if len(ip) > 0: - print_result(get_ip_info(ctx.obj['api-key'], ip=ip.strip(), fields=extract_fields)) - finish() - - -@click.command() -@click.argument('ip', required=True, type=IPAddressType()) -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.option('--api-key', required=False, default=None, help='IPData API Key') -def ip(ip, fields, api_key): - print_ip_info(get_and_check_api_key(api_key), - ip=ip, fields=fields.split(',') if fields else None) - - -def print_ip_info(api_key, ip=None, fields=None): - try: - json.dump(get_ip_info(api_key, ip, fields), stdout) - except ValueError as e: - print(f'Error: IP address {e}', file=stderr) - - -def get_ip_info(api_key, ip=None, fields=None): - ip_data = IPData(get_and_check_api_key(api_key)) - if ip: - res = ip_data.lookup(ip) - else: - res = ip_data.lookup() - if fields and len(fields) > 0: - return json_filter(res, fields) - else: - return res - - -def lookup_field(data, field): - if field in data: - return field, data[field] - elif '.' in field: - parent, children = field.split('.') - parent_field, parent_data = lookup_field(data, parent) - if parent_field: - children_field, children_data = lookup_field(parent_data, children) - return parent_field, {parent_field: children_data} - return None, None - - -# @cli.command() -# @click.argument('ip', type=str) -# @click.argument('fields', type=str, nargs=-1) -# @click.option('--api_key', required=False, default=None, help='IPData API Key') -# def ip(ip, fields, api_key): -# print_ip_info(api_key, ip, fields) - - -@cli.command() -@click.pass_context -def info(ctx): - res = IPData(get_and_check_api_key(ctx.obj['api-key'])).lookup('8.8.8.8') - print(f'Number of requests made: {res["count"]}') - - -def is_ip_address(value): - try: - ip_address(value) - return True - except ValueError: - return False - - -def todo(): - if len(sys.argv) >= 2 and is_ip_address(sys.argv[1]): - ip() - else: - cli(obj={}) - - -if __name__ == '__main__': - todo() diff --git a/build/lib/ipdata/ipdata.py b/build/lib/ipdata/ipdata.py deleted file mode 100644 index d6306a8..0000000 --- a/build/lib/ipdata/ipdata.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Call the https://api.ipdata.co API from Python.""" - -import ipaddress -import requests - - -class APIKeyNotSet(Exception): - pass - - -class IncompatibleParameters(Exception): - pass - - -class IPData: - base_url = 'https://api.ipdata.co/' - bulk_url = 'https://api.ipdata.co/bulk' - valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', - 'continent_code', 'latitude', 'longitude', 'asn', 'organisation', 'postal', 'calling_code', 'flag', - 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', - 'status'} - - def __init__(self, api_key): - if not api_key: - raise APIKeyNotSet("Missing API Key") - self.api_key = api_key - self.headers = {'user-agent': 'ipdata-pypi'} - - def _validate_fields(self, select_field=None, fields=None): - if fields is None: - fields = [] - - if select_field and select_field not in self.valid_fields: - raise ValueError(f"{select_field} is not a valid field.") - if fields: - if not isinstance(fields, list): - raise ValueError('"fields" should be a list.') - for field in fields: - if field not in self.valid_fields: - raise ValueError(f"{field} is not a valid field.") - - def _validate_ip_address(self, ip): - try: - ipaddress.ip_address(ip) - except Exception: - raise - if ipaddress.ip_address(ip).is_private: - raise ValueError(f"{ip} is a private IP Address") - - def lookup(self, ip=None, select_field=None, fields=None): - if fields is None: - fields = [] - - query = "" - query_params = {'api-key': self.api_key} - if ip: - self._validate_ip_address(ip) - query += f"{ip}/" - if select_field and fields: - raise IncompatibleParameters( - "The \"select_field\" and \"fields\" parameters cannot be used at the same time.") - if select_field: - self._validate_fields(select_field=select_field) - query += f"{select_field}/" - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.get(f"{self.base_url}{query}", headers=self.headers, params=query_params) - status_code = response.status_code - if select_field and status_code == 200: - try: - response = {select_field: response.json(), 'status': status_code} - return response - except Exception: - response = {select_field: response.text, 'status': status_code} - return response - response = response.json() - response['status'] = status_code - return response - - def bulk_lookup(self, ips=None, fields=None): - if ips is None: - ips = [] - if fields is None: - fields = [] - - query_params = {'api-key': self.api_key} - if len(ips) < 2: - raise ValueError('Bulk Lookup requires more than 1 IP Address in the payload.') - for ip in ips: - self._validate_ip_address(ip) - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.post(f"{self.bulk_url}", headers=self.headers, params=query_params, json=ips) - status_code = response.status_code - if not status_code == 200: - response = response.json() - response['status'] = status_code - return response - response = {'responses': response.json(), 'status': status_code} - return response diff --git a/build/lib/ipdata/test_cli.py b/build/lib/ipdata/test_cli.py deleted file mode 100644 index b1a56a0..0000000 --- a/build/lib/ipdata/test_cli.py +++ /dev/null @@ -1,20 +0,0 @@ -from unittest import TestCase - -from ipdata.cli import json_filter - - -class CliTestCase(TestCase): - def test_json_filter(self): - json = {'a': {'b': 1, 'c': 2}, 'd': 3} - - res = json_filter(json, ('a.b',)) - self.assertDictEqual({'a': {'b': 1}}, res) - - res = json_filter(json, ('a',)) - self.assertDictEqual({'a': {'b': 1, 'c': 2}}, res) - - res = json_filter(json, ('a.c', 'd')) - self.assertDictEqual({'a': {'c': 2}, 'd': 3}, res) - - res = json_filter(json, ('d',)) - self.assertDictEqual({'d': 3}, res) diff --git a/build/lib/ipdata/test_ipdata.py b/build/lib/ipdata/test_ipdata.py deleted file mode 100644 index 14631e7..0000000 --- a/build/lib/ipdata/test_ipdata.py +++ /dev/null @@ -1,32 +0,0 @@ -from ipdata import * -import unittest - -class TestAPIMethods(unittest.TestCase): - - def test_param_less(self): - ipdata = IPData('test') - status_code = ipdata.lookup().get('status') - self.assertEqual(status_code, 200) - - def test_param(self): - ipdata = IPData('test') - status_code = ipdata.lookup('8.8.8.8').get('status') - self.assertEqual(status_code, 200) - - def test_select_field(self): - ipdata = IPData('test') - response = ipdata.lookup('8.8.8.8', select_field='ip') - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - - def test_fields_param(self): - ipdata = IPData('test') - response = ipdata.lookup('8.8.8.8',fields=['ip']) - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - -# def test_bulk_lookup(self): -# ipdata = ipdata('paid-key-here') -# response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1'],fields=['ip']) -# self.assertEqual(response, {'response': [{'ip': '8.8.8.8'}, {'ip': '1.1.1.1'}], 'status': 200}) - -if __name__ == '__main__': - unittest.main() diff --git a/dist/ipdata-1.0.tar.gz b/dist/ipdata-1.0.tar.gz deleted file mode 100644 index 82daa4928b907ba6d190e7a3f062e87291c334f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1422 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 diff --git a/dist/ipdata-1.3.tar.gz b/dist/ipdata-1.3.tar.gz deleted file mode 100644 index 54cc7a5293ce567e5a4803c28cddf999b81f70b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1421 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 E0D*xpe*gdg diff --git a/dist/ipdata-1.5.tar.gz b/dist/ipdata-1.5.tar.gz deleted file mode 100644 index 0e1e0056dff6c9c1c688aa44e80afc84e277c612..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1422 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 diff --git a/dist/ipdata-1.7.tar.gz b/dist/ipdata-1.7.tar.gz deleted file mode 100644 index a4f40a803f35f248060a6eb37fae7d574c7c2b20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1631 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 diff --git a/dist/ipdata-2.1.tar.gz b/dist/ipdata-2.1.tar.gz deleted file mode 100644 index 6b666893bdaf91cb435b5b1d2170727f7ef7ca59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1785 zcmVbiwFoPkfL9ZQjn++`w+Rb)r3wO3QfLtn)VZ8N; zr$miOdY9fq<>f%i>vsY+pMObJF8pXXdXD+;w%d2lf4gVrztL`W1JDb)jb5;Y8*cue z9j6njh<1T96jK>xOen_F+K?p_5AgBc&hEQCe-dvw{5P?%{_X8Eq=9db|D8^wGZw}SxYzt?IuUHsq3{7(eW6wc@$Ih9Hx6eUDTY@MKwFyMkn z8s=F7`M?`1C1k%|CxZEfqeQ%Z!^CaQ(;(F|s=Pk-3U3!v z!Riga;RoLB%+`eS%Ul$eGO=Ki%BJ%N4K`dfbGdBIic=5NYM|$xfkg}Tma1h?R!)LH zqRQCem#^L*@@zs>g^2+Z)!DEIOYdX5iinWx0`AnWEQfRzj~MD(0@_+h<@C(m%Gcy;6dS#I%p`OZ?lIfwxJl#uP_9 z%V?yqoTiFgFZC!gmMTn_WOAzeV5$;qyFC7M@xNTNPnQ86)&qnp6UC~ef+Q6D)#@t`&&K3|AV&M|8E3us8TE);ScZ@ z6phhf83>sKol(Id2lFcyQ;7*anyN8m@hD+HtT2!hlfTbrjPN|fuQN*V8_E+N5uNhU z&e6V)(0&Tz_Fj*$^ryv|$jHjaV(f`>^# zBQ|6a9x|H5RUGsAWEXwhF}rJZ=nz&(zl^)H5^xgqA)ibL?1#$~@M@j{_DiS5HCVtV{_l&$6rfia`h`zu zO~7__?(Gm@#`y(QoRoVIVH!s}Ib54i(}a__c5?jo;g0J%=CdnWhY|bW9{%VH+MRzL@8i|1S#C*Jvu7!v z-8@+~EgLjhb*-DFEL#efvh7+GoqgAY{r6tA#`N85cDtN7X?_zJuDh7~cpd5CqO13LZ8 zvsm_FUS=_;3YM%qntO_lV0q{-l=FD&HrC(Y{@q>xL`v1qvic)9=e7VVS8}NK#PQTTWYr#AYr#eI&bJknMpxl+8ek zt3Ej}9^zS__O|t#GDoFASL|8i=~)%IgD3|F2L}fS2L}fS2L}fS2L}fS2L}fS2L}fS b2L}fS2L}fS2L}fShv&y%Gg?TK08jt`IN5eY diff --git a/dist/ipdata-2.2.tar.gz b/dist/ipdata-2.2.tar.gz deleted file mode 100644 index 1bfe39f6de6945fcb559a7536fba662e0ea1484a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2004 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 diff --git a/dist/ipdata-2.7.tar.gz b/dist/ipdata-2.7.tar.gz deleted file mode 100644 index 6bf83370a68084ca96cfced1ecca5f7e230b61c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34664 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 A$N&HU diff --git a/dist/ipdata-2.8-py3-none-any.whl b/dist/ipdata-2.8-py3-none-any.whl deleted file mode 100644 index 6c7d2fa16634aa70788ce0d5c498e3728bf3d8d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5496 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 AnE(I) diff --git a/dist/ipdata-2.8.tar.gz b/dist/ipdata-2.8.tar.gz deleted file mode 100644 index 3a38e0f11df4868f0104048c7d80783370d08321..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34706 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
Sample Response ``` {'asn': 'AS6167', 'calling_code': '1', @@ -79,6 +78,8 @@ Response 'name': 'America/Chicago', 'offset': '-0500'}} ``` +
+ ### Getting only one field @@ -128,7 +129,7 @@ response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1']) pprint(response) ``` -Response +
Sample Response ``` {'responses': [{'asn': 'AS15169', @@ -207,24 +208,9 @@ Response 'offset': '+1000'}}], 'status': 200} ``` +
-## Available Fields - -A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) - -## Errors - -A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) - -## Tests - -To run all tests - -``` -python3 test_ipdata.py -``` - -## ipdata CLI +## Using the ipdata CLI Usage: `ipdata [OPTIONS] COMMAND [ARGS]...` @@ -237,37 +223,68 @@ Commands: `init` `me` -### ipdata CLI Examples +#### Initialize the cli with your API Key -#### Initialize with API Key ``` ipdata init ``` -You may also pass `--api-key ` extra param to any command to -specify API Key. + +You may also pass the `--api-key ` parameter to any command to specify a different API Key. #### Lookup your own IP address + ``` ipdata ``` + or ``` ipdata me ``` -#### Look up an IP address + +#### Look up an arbitrary IP address + +``` +ipdata 8.8.8.8 +``` + +#### Filter result by specifying coma separated list of fields + ``` -ipdata +ipdata 8.8.8.8 --fields ip,country_code ``` -#### Look up an I address and filter result by specifying coma separated list of fields + +You can also use `jq` to filter the responses + ``` -ipdata --fields ip,country_code +ipdata me | jq .country_name ``` + #### Batch lookup + ``` -ipdata --output +ipdata my_ip_backlog.csv --output geolocation_results.json ``` + #### Batch lookup with output to CSV file + ``` -ipdata --output --output-format CSV --fields ip,country_code +ipdata my_ip_backlog.csv --output --output-format CSV --fields ip,country_code ``` `--fields` option is required in case of CSV output. + +## Available Fields + +A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) + +## Errors + +A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) + +## Tests + +To run all tests + +``` +python -m unittest +``` \ No newline at end of file diff --git a/ipdata/cli.py b/ipdata/cli.py index c563a28..f68de16 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -63,8 +63,7 @@ def get_and_check_api_key(api_key: str = None) -> str: if api_key is None: api_key = get_api_key() if api_key is None: - print(f'Please specify IPData API Key', file=stderr) - raise WrongAPIKey + print(f'Please provide a valid API Key', file=stderr) return api_key @@ -76,17 +75,12 @@ def init(api_key): ipdata = IPData(api_key) res = ipdata.lookup('8.8.8.8') if res['status'] == 200: - existing_api_key = get_api_key() - if existing_api_key: - print(f'Warning: You already have an IPData API Key "{existing_api_key}" listed in {key_path}. ' - f'It will be overwritten with {api_key}', - file=stderr) with open(key_path, 'w') as f: f.write(api_key) - print(f'New API Key is saved to {key_path}') + print(f'Successfully initialized.') else: - print(f'Failed to check the API Key (Error: {res["status"]}): {res["message"]}', + print(f'Setup failed. (Error: {res["status"]}): {res["message"]}', file=stderr) From c6e5ae81df37acb3c58bfcc2813d5e8b98d86e1e Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:32:58 +0300 Subject: [PATCH 013/100] Fix ci --- .github/workflows/python-publish.yml | 2 ++ .gitignore | 3 ++- README.md | 1 + ipdata/test_ipdata.py | 15 +++++++++++---- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ab8f3a5..a924eb7 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -19,6 +19,8 @@ jobs: - name: Test with unittest run: | python -m unittest + env: + IPDATA_API_KEY: ${{ secrets.IPDATA_API_KEY }} - name: Install pep517 run: >- python -m diff --git a/.gitignore b/.gitignore index 8334094..5310daa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ /*.egg-info/ /*.egg *.pyc -ipdata/__pycache__ \ No newline at end of file +ipdata/__pycache__ +.env \ No newline at end of file diff --git a/README.md b/README.md index dcd07d4..2fd8c7b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ pprint(response) ```
Sample Response + ``` {'asn': 'AS6167', 'calling_code': '1', diff --git a/ipdata/test_ipdata.py b/ipdata/test_ipdata.py index 1533b3f..cf8e804 100644 --- a/ipdata/test_ipdata.py +++ b/ipdata/test_ipdata.py @@ -1,25 +1,32 @@ from .ipdata import * +from dotenv import load_dotenv + import unittest +import os + +load_dotenv() + +ipdata_api_key = os.environ.get("IPDATA_API_KEY") class TestAPIMethods(unittest.TestCase): def test_param_less(self): - ipdata = IPData('test') + ipdata = IPData(ipdata_api_key) status_code = ipdata.lookup().get('status') self.assertEqual(status_code, 200) def test_param(self): - ipdata = IPData('test') + ipdata = IPData(ipdata_api_key) status_code = ipdata.lookup('8.8.8.8').get('status') self.assertEqual(status_code, 200) def test_select_field(self): - ipdata = IPData('test') + ipdata = IPData(ipdata_api_key) response = ipdata.lookup('8.8.8.8', select_field='ip') self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) def test_fields_param(self): - ipdata = IPData('test') + ipdata = IPData(ipdata_api_key) response = ipdata.lookup('8.8.8.8',fields=['ip']) self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) From 72aacfde15ae7ac77579399029407950f380b123 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:35:17 +0300 Subject: [PATCH 014/100] Fix ci --- .github/workflows/python-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index a924eb7..caf7cbf 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,6 +15,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install dotenv if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with unittest run: | From 78a5f844eca28a42d972379f09fa59138c086dd3 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:37:40 +0300 Subject: [PATCH 015/100] Fix ci --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index caf7cbf..4a28000 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install dotenv + python -m pip install dotenv if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with unittest run: | From 4a5f545083b4b8b3bb135681a65d86aa26359b68 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:40:42 +0300 Subject: [PATCH 016/100] Fix ci --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4a28000..77920f4 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install dotenv + sudo python -m pip install dotenv if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with unittest run: | From c4a3e8532d69a86625b2ed6090bfb3448069cc22 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:42:14 +0300 Subject: [PATCH 017/100] Fix ci --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 77920f4..85428db 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -19,7 +19,7 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with unittest run: | - python -m unittest + sudo python -m unittest env: IPDATA_API_KEY: ${{ secrets.IPDATA_API_KEY }} - name: Install pep517 From ea97dc696c370fb48668d4fb637970ba7d22730b Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:50:35 +0300 Subject: [PATCH 018/100] Fix ci --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 35239cc..b489d49 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="ipdata", - version="3.3.1", + version="3.3.6", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From a87429a4bfd9275d63585b59f4e5894833d57b88 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 13:39:16 +0300 Subject: [PATCH 019/100] Improved initialization flow --- ipdata/cli.py | 21 ++++++++++++++------- ipdata/ipdata.py | 2 +- setup.py | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index f68de16..931f5d0 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -4,7 +4,7 @@ import sys from ipaddress import ip_address from pathlib import Path -from sys import stderr, stdout +from sys import stderr, stdout, exit import click @@ -31,12 +31,16 @@ def __str__(self) -> str: return 'IP Address' -@click.group(help='CLI for IPData API', invoke_without_command=True) -@click.option('--api-key', required=False, default=None, help='IPData API Key') +@click.group(help='CLI for ipdata API', invoke_without_command=True) +@click.option('--api-key', required=False, default=None, help='ipdata API Key') @click.pass_context def cli(ctx, api_key): ctx.ensure_object(dict) - ctx.obj['api-key'] = get_and_check_api_key(api_key) + key = ctx.obj['api-key'] = get_and_check_api_key(api_key) + if not ctx.invoked_subcommand == "init": + if key is None: + print(f'Please initialize the cli by running "ipdata init " then try again', file=stderr) + sys.exit(1) if ctx.invoked_subcommand is None: print_ip_info(api_key) else: @@ -62,8 +66,6 @@ def get_api_key(): def get_and_check_api_key(api_key: str = None) -> str: if api_key is None: api_key = get_api_key() - if api_key is None: - print(f'Please provide a valid API Key', file=stderr) return api_key @@ -123,6 +125,7 @@ def me(ctx, fields): @click.pass_context def batch(ctx, ip_list, output, output_format, fields): extract_fields = fields.split(',') if fields else None + output_format = output_format.upper() if output_format == 'CSV' and extract_fields is None: print(f'Output in CSV format is not supported without specification of exactly fields to extract ' @@ -177,7 +180,11 @@ def print_ip_info(api_key, ip=None, fields=None): def get_ip_info(api_key, ip=None, fields=None): - ip_data = IPData(get_and_check_api_key(api_key)) + api_key = get_and_check_api_key(api_key) + if api_key is None: + print(f'Please initialize the cli by running "ipdata init " then try again or pass an API key with the --api-key option', file=stderr) + sys.exit(1) + ip_data = IPData(api_key) if ip: res = ip_data.lookup(ip) else: diff --git a/ipdata/ipdata.py b/ipdata/ipdata.py index d6306a8..0b95ee1 100644 --- a/ipdata/ipdata.py +++ b/ipdata/ipdata.py @@ -16,7 +16,7 @@ class IPData: base_url = 'https://api.ipdata.co/' bulk_url = 'https://api.ipdata.co/bulk' valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', - 'continent_code', 'latitude', 'longitude', 'asn', 'organisation', 'postal', 'calling_code', 'flag', + 'continent_code', 'latitude', 'longitude', 'asn', 'postal', 'calling_code', 'flag', 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', 'status'} diff --git a/setup.py b/setup.py index b489d49..9500745 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="ipdata", - version="3.3.6", + version="3.3.7", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From c04dacd384d62d5fcf5db2028a9b8d8206ca32bd Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 13:40:07 +0300 Subject: [PATCH 020/100] bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9500745..b26795c 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="ipdata", - version="3.3.7", + version="3.3.8", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From 0958b02720f53f84f23f4d6bd8561e2594ca4d89 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 14:13:32 +0300 Subject: [PATCH 021/100] Updated Readme --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2fd8c7b..9e81a42 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ -# Getting Started +[![PyPI version](https://badge.fury.io/py/ipdata.svg)](https://badge.fury.io/py/ipdata) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/ipdata/python/Test%20and%20Publish%20ipdata%20to%20PyPI) + +# Official Python client library and CLI for the ipdata API This is a Python client and command line interface (CLI) for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. -Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, [Sign up here](https://ipdata.co/sign-up.html) . If you need higher volume then [Sign up for a paid plan](https://ipdata.co/pricing.html). +Note that you need an API Key to use this package. You can get a free one with a 1,500 daily request limit by [Signing up here](https://ipdata.co/sign-up.html). Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutorials. +[![asciicast](https://asciinema.org/a/371292.svg)](https://asciinema.org/a/371292) + ## Installation ``` From 9c1bcd1224ade3070a681246e5ead25472d1684e Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 15:42:42 +0300 Subject: [PATCH 022/100] Fix typo in readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e81a42..8f8fcf1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ pip install ipdata ## Library Usage -### Looking Up the Calling IP Address +### Looking up the calling IP Address ``` from ipdata import ipdata @@ -29,7 +29,7 @@ response = ipdata.lookup() pprint(response) ``` -### Looking Up any IP Address +### Looking up any IP Address ``` from ipdata import ipdata @@ -253,7 +253,7 @@ ipdata me ipdata 8.8.8.8 ``` -#### Filter result by specifying coma separated list of fields +#### Filter results by specifying comma separated list of fields ``` ipdata 8.8.8.8 --fields ip,country_code From eff6f55bd502f86386354c1b089c89183327c9eb Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 16:29:10 +0300 Subject: [PATCH 023/100] Updated field descriptions --- README.md | 2 +- ipdata/cli.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8f8fcf1..0c0d83a 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ ipdata init You may also pass the `--api-key ` parameter to any command to specify a different API Key. -#### Lookup your own IP address +#### Look up your own IP address ``` ipdata diff --git a/ipdata/cli.py b/ipdata/cli.py index 931f5d0..5e15a15 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -109,7 +109,7 @@ def json_filter(json, fields): @cli.command() -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') +@click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') @click.pass_context def me(ctx, fields): print_ip_info(ctx.obj['api-key'], ip=None, fields=fields.split(',') if fields else None) @@ -121,15 +121,14 @@ def me(ctx, fields): help='Output to file or stdout') @click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', help='Format of output') -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') +@click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') @click.pass_context def batch(ctx, ip_list, output, output_format, fields): extract_fields = fields.split(',') if fields else None output_format = output_format.upper() if output_format == 'CSV' and extract_fields is None: - print(f'Output in CSV format is not supported without specification of exactly fields to extract ' - f'because of plain nature of CSV format. Please use JSON format instead.', file=stderr) + print(f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', file=stderr) return result_context = {} @@ -165,8 +164,8 @@ def finish(): @click.command() @click.argument('ip', required=True, type=IPAddressType()) -@click.option('--fields', required=False, type=str, default=None, help='Coma separated list of fields to extract') -@click.option('--api-key', required=False, default=None, help='IPData API Key') +@click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') +@click.option('--api-key', required=False, default=None, help='ipdata API Key') def ip(ip, fields, api_key): print_ip_info(get_and_check_api_key(api_key), ip=ip, fields=fields.split(',') if fields else None) From ddf7fc02c29f837c13eabc11685a13ae182212fc Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 17:46:59 +0300 Subject: [PATCH 024/100] Bump version --- ipdata/cli.py | 1 - setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 5e15a15..2b533d4 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -125,7 +125,6 @@ def me(ctx, fields): @click.pass_context def batch(ctx, ip_list, output, output_format, fields): extract_fields = fields.split(',') if fields else None - output_format = output_format.upper() if output_format == 'CSV' and extract_fields is None: print(f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', file=stderr) diff --git a/setup.cfg b/setup.cfg index 6110861..0111d50 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.3.1 +current_version = 3.3.9 [metadata] description-file = README.md diff --git a/setup.py b/setup.py index b26795c..cb3afab 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="ipdata", - version="3.3.8", + version="3.3.9", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From e5ae112bff3b9dbd169629644643af970507d8b6 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 18:29:32 +0300 Subject: [PATCH 025/100] Polished Readme --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0c0d83a..99f8d6e 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,28 @@ Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutoria ## Installation +Install the latest version of the cli with `pip`. + ``` pip install ipdata ``` +or `easy_install` + +``` +easy_install ipdata +``` + ## Library Usage +You need a valid API key from ipdata to use the library. You can get a free key by [Signing up here](https://ipdata.co/sign-up.html). + +Replace `test` with your API Key in the following examples. + ### Looking up the calling IP Address +You can look up the calling IP address, that is, the IP address of the computer you are running this on by not passing an IP address to the `lookup` method. + ``` from ipdata import ipdata from pprint import pprint @@ -31,6 +45,8 @@ pprint(response) ### Looking up any IP Address +You can look up any valid IPv4 or IPv6 address by passing it to the `lookup` method. + ``` from ipdata import ipdata from pprint import pprint @@ -88,6 +104,8 @@ pprint(response) ### Getting only one field +If you only need a single data attribute about an IP address you can extract it by passing a `select_field` parameter to the `lookup` method. + ``` from ipdata import ipdata from pprint import pprint @@ -105,6 +123,8 @@ Response ### Getting a number of specific fields +If instead you need to get multiple specific fields you can pass a list of valid field names in a `fields` parameter. + ``` from ipdata import ipdata from pprint import pprint @@ -125,6 +145,10 @@ Response ### Bulk Lookups +The API provides a `/bulk` endpoint that allows you to look up upto 100 IP addresses at a time. This is convenient for quickly clearing your backlog. + +NOTE: Alternatively it is much simpler to process bulk lookups using the `ipdata` cli's `batch` command. All you need is a csv file with a list of IP addresses and you can get your results as either a JSON file or a CSV file! See the [CLI Bulk Lookup Documentation](https://docs.ipdata.co/command-line-interface/bulk-lookups-recommended) for details. + ``` from ipdata import ipdata from pprint import pprint @@ -217,66 +241,105 @@ pprint(response) ## Using the ipdata CLI -Usage: `ipdata [OPTIONS] COMMAND [ARGS]...` +### Available commands + +``` +ipdata --help +Usage: ipdata [OPTIONS] COMMAND [ARGS]... + + CLI for ipdata API Options: - `--api-key` TEXT IPData API Key + --api-key TEXT ipdata API Key + --help Show this message and exit. Commands: - `batch` - `info` - `init` - `me` + batch + info + init + me +``` -#### Initialize the cli with your API Key +### Initialize the cli with your API Key + +You need a valid API key from ipdata to use the cli. You can get a free key by [Signing up here](https://ipdata.co/sign-up.html). ``` ipdata init ``` -You may also pass the `--api-key ` parameter to any command to specify a different API Key. +You can also pass the `--api-key ` parameter to any command to specify a different API Key. + +### Look up your own IP address + +Running the `ipdata` command without any parameters will look up the IP address of the computer you are running the command on. Alternatively you can explicitly look up your own IP address by running `ipdata me` . You can filter the JSON response with `jq` to get any specific fields you might be interested in. -#### Look up your own IP address ``` ipdata ``` or + ``` ipdata me ``` -#### Look up an arbitrary IP address +Using `jq` to filter the responses + +``` +ipdata me | jq .country_name +``` + +### Look up an arbitrary IP address + +You can pass any valid IPv4 or IPv6 address to the `ipdata` command to look it up. In case an invalid value is passed you will get the error `Error: No such command "1..@1....1..1"`. ``` ipdata 8.8.8.8 ``` -#### Filter results by specifying comma separated list of fields +### Filter results by specifying comma separated list of fields + +In case you don't want to use `jq` to filter responses to get specific fields you can instead pass a fields argument to the `ipdata` command along with a comma separated list of valid fields. Invalid fields are ignored. It is important to not include any whitespace in the list. + +To access fields within nested objects eg. in the case of the `asn`, `languages`, `currency`, `time_zone` and `threat` objects, you can get a nested field by using dot notation with the name of the object and the name of the field. For example to get the time_zone name you would use `time_zone.name`, to get the time_zone abbreviation you would use `time_zone.abbr` ``` ipdata 8.8.8.8 --fields ip,country_code ``` -You can also use `jq` to filter the responses +### Batch lookup + +Perhaps the most useful command provided by the CLI is the ability to process a csv file with a list of IP addresses and write the results to file as either CSV or JSON! It could be a list of tens of thousands to millions of IP addresses and it will all be processed and the results filtered to your liking! +When you use the JSON output format, the results are written to the output file you provide with one result per line. Each line being a valid and full JSON response object. +If you only need a few fields eg. only the country name you can specify a field argument with the names of the fields you want, if you combine this with the CSV output format you will get very clean results with only the data you need! + +### To get full JSON responses for further processing ``` -ipdata me | jq .country_name +ipdata batch my_ip_backlog.csv --output geolocation_results.json ``` -#### Batch lookup +### Batch lookup with output to CSV file ``` -ipdata my_ip_backlog.csv --output geolocation_results.json +ipdata batch my_ip_backlog.csv --output results.csv --output-format CSV --fields ip,country_code ``` -#### Batch lookup with output to CSV file +The `--fields` option is required in case of CSV output. + +#### Example Results ``` -ipdata my_ip_backlog.csv --output --output-format CSV --fields ip,country_code +# ip,country_code +107.175.75.83,US +35.155.95.229,US +13.0.0.164,US +209.248.120.14,US +142.0.202.238,US +... ``` -`--fields` option is required in case of CSV output. ## Available Fields From 3e6722bf4dece0bdcb2aaff5602a1c6579864049 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 18:37:45 +0300 Subject: [PATCH 026/100] Updated Readme examples --- README.md | 202 +++++++++++++++++++++++++++++------------------------- 1 file changed, 110 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 99f8d6e..9b63657 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,17 @@ pprint(response)
Sample Response ``` -{'asn': 'AS6167', +{'asn': {'asn': 'AS6167', + 'domain': 'verizonwireless.com', + 'name': 'Cellco Partnership DBA Verizon Wireless', + 'route': '69.78.0.0/16', + 'type': 'business'}, 'calling_code': '1', 'carrier': {'mcc': '310', 'mnc': '004', 'name': 'Verizon'}, - 'city': 'Farmersville', + 'city': None, 'continent_code': 'NA', 'continent_name': 'North America', - 'count': '1506', + 'count': '1527', 'country_code': 'US', 'country_name': 'United States', 'currency': {'code': 'USD', @@ -79,12 +83,11 @@ pprint(response) 'ip': '69.78.70.144', 'is_eu': False, 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 33.1659, - 'longitude': -96.3686, - 'organisation': 'Cellco Partnership DBA Verizon Wireless', - 'postal': '75442', - 'region': 'Texas', - 'region_code': 'TX', + 'latitude': 37.751, + 'longitude': -97.822, + 'postal': None, + 'region': None, + 'region_code': None, 'status': 200, 'threat': {'is_anonymous': False, 'is_bogon': False, @@ -93,11 +96,11 @@ pprint(response) 'is_proxy': False, 'is_threat': False, 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T17:56:59.246755-05:00', - 'is_dst': True, + 'time_zone': {'abbr': 'CST', + 'current_time': '2020-11-08T09:31:10.629425-06:00', + 'is_dst': False, 'name': 'America/Chicago', - 'offset': '-0500'}} + 'offset': '-0600'}} ```
@@ -111,14 +114,19 @@ from ipdata import ipdata from pprint import pprint # Create an instance of an ipdata object. Replace `test` with your API Key ipdata = ipdata.IPData('test') -response = ipdata.lookup('8.8.8.8', select_field='organisation') +response = ipdata.lookup('8.8.8.8', select_field='asn') pprint(response) ``` Response ``` -{'organisation': 'Google LLC', 'status': 200} +{'asn': {'asn': 'AS15169', + 'domain': 'google.com', + 'name': 'Google LLC', + 'route': '8.8.8.0/24', + 'type': 'hosting'}, + 'status': 200 ``` ### Getting a number of specific fields @@ -130,16 +138,20 @@ from ipdata import ipdata from pprint import pprint # Create an instance of an ipdata object. Replace `test` with your API Key ipdata = ipdata.IPData('test') -response = ipdata.lookup('8.8.8.8',fields=['ip','organisation','country_name']) +response = ipdata.lookup('8.8.8.8',fields=['ip','asn','country_name']) pprint(response) ``` Response ``` -{'country_name': 'United States', +{'asn': {'asn': 'AS15169', + 'domain': 'google.com', + 'name': 'Google LLC', + 'route': '8.8.8.0/24', + 'type': 'hosting'}, + 'country_name': 'United States', 'ip': '8.8.8.8', - 'organisation': 'Google LLC', 'status': 200} ``` @@ -161,80 +173,86 @@ pprint(response)
Sample Response ``` -{'responses': [{'asn': 'AS15169', - 'calling_code': '1', - 'city': None, - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '8.8.8.8', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 37.751, - 'longitude': -97.822, - 'organisation': 'Google LLC', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T18:02:48.035425-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}}, - {'asn': 'AS13335', - 'calling_code': '61', - 'city': None, - 'continent_code': 'OC', - 'continent_name': 'Oceania', - 'count': '1506', - 'country_code': 'AU', - 'country_name': 'Australia', - 'currency': {'code': 'AUD', - 'name': 'Australian Dollar', - 'native': '$', - 'plural': 'Australian dollars', - 'symbol': 'AU$'}, - 'emoji_flag': '🇦🇺', - 'emoji_unicode': 'U+1F1E6 U+1F1FA', - 'flag': 'https://ipdata.co/flags/au.png', - 'ip': '1.1.1.1', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': -33.494, - 'longitude': 143.2104, - 'organisation': 'Cloudflare, Inc.', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'AEST', - 'current_time': '2019-04-29T09:02:48.036287+10:00', - 'is_dst': False, - 'name': 'Australia/Sydney', - 'offset': '+1000'}}], +{'responses': [{'asn': {'asn': 'AS15169', + 'domain': 'google.com', + 'name': 'Google LLC', + 'route': '8.8.8.0/24', + 'type': 'hosting'}, + 'calling_code': '1', + 'city': None, + 'continent_code': 'NA', + 'continent_name': 'North America', + 'count': '1527', + 'country_code': 'US', + 'country_name': 'United States', + 'currency': {'code': 'USD', + 'name': 'US Dollar', + 'native': '$', + 'plural': 'US dollars', + 'symbol': '$'}, + 'emoji_flag': '🇺🇸', + 'emoji_unicode': 'U+1F1FA U+1F1F8', + 'flag': 'https://ipdata.co/flags/us.png', + 'ip': '8.8.8.8', + 'is_eu': False, + 'languages': [{'name': 'English', 'native': 'English'}], + 'latitude': 37.751, + 'longitude': -97.822, + 'postal': None, + 'region': None, + 'region_code': None, + 'threat': {'is_anonymous': False, + 'is_bogon': False, + 'is_known_abuser': False, + 'is_known_attacker': False, + 'is_proxy': False, + 'is_threat': False, + 'is_tor': False}, + 'time_zone': {'abbr': 'CST', + 'current_time': '2020-11-08T09:34:45.362725-06:00', + 'is_dst': False, + 'name': 'America/Chicago', + 'offset': '-0600'}}, + {'asn': {'asn': 'AS13335', + 'domain': 'cloudflare.com', + 'name': 'Cloudflare, Inc.', + 'route': '1.1.1.0/24', + 'type': 'hosting'}, + 'calling_code': '61', + 'city': None, + 'continent_code': 'OC', + 'continent_name': 'Oceania', + 'count': '1527', + 'country_code': 'AU', + 'country_name': 'Australia', + 'currency': {'code': 'AUD', + 'name': 'Australian Dollar', + 'native': '$', + 'plural': 'Australian dollars', + 'symbol': 'AU$'}, + 'emoji_flag': '🇦🇺', + 'emoji_unicode': 'U+1F1E6 U+1F1FA', + 'flag': 'https://ipdata.co/flags/au.png', + 'ip': '1.1.1.1', + 'is_eu': False, + 'languages': [{'name': 'English', 'native': 'English'}], + 'latitude': -33.494, + 'longitude': 143.2104, + 'postal': None, + 'region': None, + 'region_code': None, + 'threat': {'is_anonymous': False, + 'is_bogon': False, + 'is_known_abuser': False, + 'is_known_attacker': False, + 'is_proxy': False, + 'is_threat': False, + 'is_tor': False}, + 'time_zone': {'abbr': 'AEDT', + 'current_time': '2020-11-09T02:34:45.364564+11:00', + 'is_dst': True, + 'name': 'Australia/Sydney', + 'offset': '+1100'}}], 'status': 200} ```
From 698eeb37978b8200b79fac91056c3ccee248b9ce Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Mon, 9 Nov 2020 23:58:16 +0300 Subject: [PATCH 027/100] Use IPData bulk_lookup for batch command --- ipdata/cli.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index c563a28..7ff6810 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -141,16 +141,15 @@ def batch(ctx, ip_list, output, output_format, fields): result_context['writer'] = csv.writer(output) def print_result(res): - result_context['writer'].writerow([res[k] for k in extract_fields]) + for result in res['responses']: + result_context['writer'].writerow([result[k] for k in extract_fields]) def finish(): pass elif output_format == 'JSON': - result_context['results'] = [] - def print_result(res): - result_context['results'].append(res) + result_context['results'] = res['responses'] def finish(): json.dump(result_context, fp=output) @@ -159,10 +158,13 @@ def finish(): print(f'Unsupported format: {output_format}', file=stderr) return - for ip in ip_list: - ip = ip.strip() - if len(ip) > 0: - print_result(get_ip_info(ctx.obj['api-key'], ip=ip.strip(), fields=extract_fields)) + ip_data = IPData(get_and_check_api_key(ctx.obj['api-key'])) + res = ip_data.bulk_lookup( + list( + filter(lambda ip: len(ip) > 0, + [ip.strip() for ip in ip_list]) + ), extract_fields) + print_result(res) finish() From ddfb1aba58a030fdb156770c02bfcf1bace529a4 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Tue, 10 Nov 2020 00:20:35 +0300 Subject: [PATCH 028/100] Pass fields list to lookup directly --- ipdata/cli.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 70a75f9..888c129 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -112,7 +112,7 @@ def json_filter(json, fields): @click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') @click.pass_context def me(ctx, fields): - print_ip_info(ctx.obj['api-key'], ip=None, fields=fields.split(',') if fields else None) + print_ip_info(ctx.obj['api-key'], ip=None, fields=fields) @cli.command() @@ -168,15 +168,14 @@ def finish(): @click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') @click.option('--api-key', required=False, default=None, help='ipdata API Key') def ip(ip, fields, api_key): - print_ip_info(get_and_check_api_key(api_key), - ip=ip, fields=fields.split(',') if fields else None) + print_ip_info(get_and_check_api_key(api_key), ip=ip, fields=fields) def print_ip_info(api_key, ip=None, fields=None): try: json.dump(get_ip_info(api_key, ip, fields), stdout) except ValueError as e: - print(f'Error: IP address {e}', file=stderr) + print(f'Error: {e}', file=stderr) def get_ip_info(api_key, ip=None, fields=None): @@ -185,14 +184,7 @@ def get_ip_info(api_key, ip=None, fields=None): print(f'Please initialize the cli by running "ipdata init " then try again or pass an API key with the --api-key option', file=stderr) sys.exit(1) ip_data = IPData(api_key) - if ip: - res = ip_data.lookup(ip) - else: - res = ip_data.lookup() - if fields and len(fields) > 0: - return json_filter(res, fields) - else: - return res + return ip_data.lookup(ip, fields=fields.split(',') if fields else None) def lookup_field(data, field): From 9522fa911e90e14ba3d507aa10332122c10cff1b Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Tue, 10 Nov 2020 01:03:43 +0300 Subject: [PATCH 029/100] Slice ip list by 100 ip per chunk when pass to batch. --- ipdata/cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 888c129..1722078 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -154,12 +154,12 @@ def finish(): return ip_data = IPData(get_and_check_api_key(ctx.obj['api-key'])) - res = ip_data.bulk_lookup( - list( - filter(lambda ip: len(ip) > 0, - [ip.strip() for ip in ip_list]) - ), extract_fields) - print_result(res) + ips = list( + filter(lambda ip: len(ip) > 0, [ip.strip() for ip in ip_list]) + ) + for i in range(0, len(ips), 100): + res = ip_data.bulk_lookup(ips[i:i+100], extract_fields) + print_result(res) finish() From ea8cc38a2bb19ca9a79ef537bb8c235d66268ad2 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 10 Nov 2020 14:26:34 +0300 Subject: [PATCH 030/100] v3.4.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cb3afab..f207250 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="ipdata", - version="3.3.9", + version="3.4.0", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From 419368d712e7e1079ec3cab69d7fe41b7828f1d4 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Tue, 10 Nov 2020 16:46:59 +0300 Subject: [PATCH 031/100] Re-implemented filtering for ip, me and batch lookups. --- ipdata/cli.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 1722078..e4ee9ba 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -4,9 +4,10 @@ import sys from ipaddress import ip_address from pathlib import Path -from sys import stderr, stdout, exit +from sys import stderr, stdout import click +from setuptools._vendor.ordered_set import OrderedSet if __name__ == '__main__': from ipdata import IPData @@ -86,6 +87,18 @@ def init(api_key): file=stderr) +def get_json_value(json, name): + if name in json: + return json[name] + elif name.find('.') != -1: + parts = name.split('.') + part = parts[0] if len(parts) > 1 else None + if part and part in json: + return get_json_value(json[part], '.'.join(parts[1:])) + else: + return None + + def json_filter(json, fields): res = dict() for name in fields: @@ -137,7 +150,9 @@ def batch(ctx, ip_list, output, output_format, fields): def print_result(res): for result in res['responses']: - result_context['writer'].writerow([result[k] for k in extract_fields]) + result_context['writer'].writerow( + [get_json_value(result, k) for k in extract_fields] + ) def finish(): pass @@ -157,9 +172,14 @@ def finish(): ips = list( filter(lambda ip: len(ip) > 0, [ip.strip() for ip in ip_list]) ) + + @filter_json_response(batch=True) + def get_bulk_result(ip_chunk, fields): + res = ip_data.bulk_lookup(ip_chunk, fields=fields) + return res + for i in range(0, len(ips), 100): - res = ip_data.bulk_lookup(ips[i:i+100], extract_fields) - print_result(res) + print_result(get_bulk_result(ips[i:i + 100], fields=fields)) finish() @@ -178,13 +198,43 @@ def print_ip_info(api_key, ip=None, fields=None): print(f'Error: {e}', file=stderr) +def filter_json_response(batch=False): + def decorator(func): + def wrapper(*args, **kwargs): + if 'fields' in kwargs: + fields = kwargs['fields'] + prepared_fields = OrderedSet(filter( + lambda x: len(x.strip()) > 0, + fields.split(',') if fields else None + )) + plain_fields = list(OrderedSet(map(lambda f: f.split('.')[0], prepared_fields))) + + del kwargs['fields'] + kwargs['fields'] = plain_fields + + if batch: + responses = func(*args, **kwargs) + filtered_responses = [] + for r in responses['responses']: + filtered_responses.append(json_filter(r, prepared_fields)) + responses['responses'] = filtered_responses + return responses + else: + return json_filter(func(*args, **kwargs), prepared_fields) + else: + return func(*args, **kwargs) + return wrapper + return decorator + + +@filter_json_response def get_ip_info(api_key, ip=None, fields=None): api_key = get_and_check_api_key(api_key) if api_key is None: print(f'Please initialize the cli by running "ipdata init " then try again or pass an API key with the --api-key option', file=stderr) sys.exit(1) ip_data = IPData(api_key) - return ip_data.lookup(ip, fields=fields.split(',') if fields else None) + return ip_data.lookup(ip, fields=fields) def lookup_field(data, field): From c68dd5107c6c4be945042ca83cf2b1d74f12757d Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 10 Nov 2020 17:27:17 +0300 Subject: [PATCH 032/100] v3.4.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f207250..956948c 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ # This call to setup() does all the work setup( name="ipdata", - version="3.4.0", + version="3.4.1", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From 7fd2444c6878dd61f8b5ceba535732b9eb8cf923 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Tue, 10 Nov 2020 21:55:45 +0300 Subject: [PATCH 033/100] 1. Fixed case sensitive of option format 2. Fixed bugs in commands related to fields filtering. --- ipdata/cli.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index e4ee9ba..317e42d 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -132,12 +132,13 @@ def me(ctx, fields): @click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) @click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), help='Output to file or stdout') -@click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', - help='Format of output') +@click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), + default='JSON', help='Format of output') @click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') @click.pass_context def batch(ctx, ip_list, output, output_format, fields): extract_fields = fields.split(',') if fields else None + output_format = output_format.upper() if output_format == 'CSV' and extract_fields is None: print(f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', file=stderr) @@ -193,7 +194,7 @@ def ip(ip, fields, api_key): def print_ip_info(api_key, ip=None, fields=None): try: - json.dump(get_ip_info(api_key, ip, fields), stdout) + json.dump(get_ip_info(api_key, ip, fields=fields), stdout) except ValueError as e: print(f'Error: {e}', file=stderr) @@ -201,7 +202,7 @@ def print_ip_info(api_key, ip=None, fields=None): def filter_json_response(batch=False): def decorator(func): def wrapper(*args, **kwargs): - if 'fields' in kwargs: + if 'fields' in kwargs and kwargs['fields']: fields = kwargs['fields'] prepared_fields = OrderedSet(filter( lambda x: len(x.strip()) > 0, @@ -227,7 +228,7 @@ def wrapper(*args, **kwargs): return decorator -@filter_json_response +@filter_json_response() def get_ip_info(api_key, ip=None, fields=None): api_key = get_and_check_api_key(api_key) if api_key is None: From d34ab535b94fddf3c9f08f9f05356903f2f50354 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 11 Nov 2020 00:36:02 +0300 Subject: [PATCH 034/100] v3.4.2 --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 956948c..496143e 100644 --- a/setup.py +++ b/setup.py @@ -8,9 +8,10 @@ README = (HERE / "README.md").read_text() # This call to setup() does all the work +# ToDo: add bumpversion, run in ci setup( name="ipdata", - version="3.4.1", + version="3.4.2", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From a6a6c25779cb0dcd7370c6e75f65ac6eb83a4743 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Wed, 11 Nov 2020 01:38:31 +0300 Subject: [PATCH 035/100] Implementing unit-tests for CLI --- ipdata/cli.py | 21 --------------------- ipdata/test_cli.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 317e42d..a7d496f 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -78,7 +78,6 @@ def init(api_key): ipdata = IPData(api_key) res = ipdata.lookup('8.8.8.8') if res['status'] == 200: - with open(key_path, 'w') as f: f.write(api_key) print(f'Successfully initialized.') @@ -238,26 +237,6 @@ def get_ip_info(api_key, ip=None, fields=None): return ip_data.lookup(ip, fields=fields) -def lookup_field(data, field): - if field in data: - return field, data[field] - elif '.' in field: - parent, children = field.split('.') - parent_field, parent_data = lookup_field(data, parent) - if parent_field: - children_field, children_data = lookup_field(parent_data, children) - return parent_field, {parent_field: children_data} - return None, None - - -# @cli.command() -# @click.argument('ip', type=str) -# @click.argument('fields', type=str, nargs=-1) -# @click.option('--api_key', required=False, default=None, help='IPData API Key') -# def ip(ip, fields, api_key): -# print_ip_info(api_key, ip, fields) - - @cli.command() @click.pass_context def info(ctx): diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 228bd74..1842f92 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -1,6 +1,10 @@ +from io import StringIO +import sys +import unittest from unittest import TestCase +from unittest.mock import patch -from ipdata.cli import json_filter +from ipdata.cli import json_filter, todo, ip class CliTestCase(TestCase): @@ -19,5 +23,42 @@ def test_json_filter(self): res = json_filter(json, ('d',)) self.assertDictEqual({'d': 3}, res) + +class CliTodoTestCase(TestCase): + def test_todo_call_with_ip_address(self): + with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']) as m1, \ + patch('ipdata.cli.ip') as m2, \ + patch('ipdata.cli.cli') as m3: + todo() + m3.assert_not_called() + m2.assert_called_once() + + def test_todo_call_with_param(self): + with patch.object(sys, 'argv', ['__nope__', 'abc']) as m1, \ + patch('ipdata.cli.ip') as m2, \ + patch('ipdata.cli.cli') as m3: + todo() + m2.assert_not_called() + m3.assert_called_once() + + def test_todo_call_without_params(self): + with patch.object(sys, 'argv', ['__nope__']) as m1, \ + patch('ipdata.cli.ip') as m2, \ + patch('ipdata.cli.cli') as m3: + todo() + m2.assert_not_called() + m3.assert_called_once() + + +class CliInitTestCase(TestCase): + def test_init_noargs(self): + with patch.object(sys, 'argv', ['__nope__', 'init']) as m1, \ + patch('ipdata.cli.init') as m2, \ + patch('sys.exit') as m3: + todo() + m2.assert_not_called() + m3.assert_called_once_with(2) + + if __name__ == '__main__': unittest.main() From a50329fcaac7cd82b2544764663cf3754b5d82be Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 12 Nov 2020 22:28:50 +0300 Subject: [PATCH 036/100] Let API validate IP addresses in bulk lookup --- ipdata/ipdata.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ipdata/ipdata.py b/ipdata/ipdata.py index 0b95ee1..a67ee30 100644 --- a/ipdata/ipdata.py +++ b/ipdata/ipdata.py @@ -87,8 +87,6 @@ def bulk_lookup(self, ips=None, fields=None): query_params = {'api-key': self.api_key} if len(ips) < 2: raise ValueError('Bulk Lookup requires more than 1 IP Address in the payload.') - for ip in ips: - self._validate_ip_address(ip) if fields: self._validate_fields(fields=fields) query_params['fields'] = ','.join(fields) From fde1788c3b84dd84f0780ab3bc55ea558e4df34f Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Thu, 12 Nov 2020 23:12:43 +0300 Subject: [PATCH 037/100] Added tqdm progress bar and multicore support to batch lookup --- ipdata/cli.py | 97 ++++++++++++++++++++++++++++++++---------------- requirements.txt | 3 +- setup.py | 2 +- 3 files changed, 68 insertions(+), 34 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index a7d496f..5352207 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -1,5 +1,6 @@ import csv import json +import multiprocessing import os import sys from ipaddress import ip_address @@ -8,8 +9,9 @@ import click from setuptools._vendor.ordered_set import OrderedSet +from tqdm import tqdm -if __name__ == '__main__': +if __name__ == '__main__' or __name__ == '__mp_main__': from ipdata import IPData else: from .ipdata import IPData @@ -127,6 +129,16 @@ def me(ctx, fields): print_ip_info(ctx.obj['api-key'], ip=None, fields=fields) +def do_lookup(ip_chunk, fields, api_key): + ip_data = IPData(get_and_check_api_key(api_key)) + + @filter_json_response(batch=True) + def get_bulk_result(ip_chunk, fields): + return ip_data.bulk_lookup(ip_chunk, fields=fields) + + return get_bulk_result(ip_chunk, fields=fields) + + @cli.command() @click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) @click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), @@ -134,8 +146,10 @@ def me(ctx, fields): @click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', help='Format of output') @click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') +@click.option('--workers', '-w', 'workers', required=False, type=int, default=multiprocessing.cpu_count(), + help='Number of workers') @click.pass_context -def batch(ctx, ip_list, output, output_format, fields): +def batch(ctx, ip_list, output, output_format, fields, workers): extract_fields = fields.split(',') if fields else None output_format = output_format.upper() @@ -143,44 +157,63 @@ def batch(ctx, ip_list, output, output_format, fields): print(f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', file=stderr) return - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) + def filter_ip(value): + value = value.strip() + try: + ip_address(value) + return True + except: + return False + + with tqdm(total=0) as t, \ + multiprocessing.Pool(workers) as pool: + ips = list( + filter(filter_ip, [ip.strip() for ip in ip_list]) + ) - def print_result(res): - for result in res['responses']: - result_context['writer'].writerow( - [get_json_value(result, k) for k in extract_fields] - ) + result_context = {} + if output_format == 'CSV': + print(f'# {fields}', file=output) # print comment with columns + result_context['writer'] = csv.writer(output) - def finish(): - pass + def print_result(res): + for result in res['responses']: + t.update() + result_context['writer'].writerow( + [get_json_value(result, k) for k in extract_fields] + ) - elif output_format == 'JSON': - def print_result(res): - result_context['results'] = res['responses'] + def finish(): + pass - def finish(): - json.dump(result_context, fp=output) + elif output_format == 'JSON': + def print_result(res): + t.update(len(res['responses'])) + result_context['results'] = res['responses'] - else: - print(f'Unsupported format: {output_format}', file=stderr) - return + def finish(): + json.dump(result_context, fp=output) - ip_data = IPData(get_and_check_api_key(ctx.obj['api-key'])) - ips = list( - filter(lambda ip: len(ip) > 0, [ip.strip() for ip in ip_list]) - ) + else: + print(f'Unsupported format: {output_format}', file=stderr) + return - @filter_json_response(batch=True) - def get_bulk_result(ip_chunk, fields): - res = ip_data.bulk_lookup(ip_chunk, fields=fields) - return res + def handle_error(e): + print(e) + + for i in range(0, len(ips), 100): + chunk = ips[i:i + 100] + t.total += len(chunk) + + pool.apply_async(do_lookup, + args=[chunk], + kwds=dict(fields=fields, api_key=ctx.obj['api-key']), + callback=print_result, + error_callback=handle_error) + pool.close() + pool.join() - for i in range(0, len(ips), 100): - print_result(get_bulk_result(ips[i:i + 100], fields=fields)) - finish() + finish() @click.command() diff --git a/requirements.txt b/requirements.txt index 23adf64..871367c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests -click \ No newline at end of file +click +tqdm diff --git a/setup.py b/setup.py index 496143e..81f9198 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ ], packages=["ipdata"], include_package_data=True, - install_requires=["requests", "ipaddress", "click"], + install_requires=["requests", "ipaddress", "click", "tqdm"], entry_points={ 'console_scripts': [ 'ipdata = ipdata.cli:todo', From ac46aa4b86a08d01ac466572e1c94a2010e26d20 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Thu, 12 Nov 2020 23:23:17 +0300 Subject: [PATCH 038/100] Fixed larger results issue. --- ipdata/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 5352207..8605dbf 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -187,9 +187,11 @@ def finish(): pass elif output_format == 'JSON': + result_context['results'] = [] + def print_result(res): t.update(len(res['responses'])) - result_context['results'] = res['responses'] + result_context['results'].append(res['responses']) def finish(): json.dump(result_context, fp=output) @@ -212,6 +214,7 @@ def handle_error(e): error_callback=handle_error) pool.close() pool.join() + t.close() finish() From 24a2316f47134b3e6e149ccae12bf39d4b6f9ef7 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Thu, 12 Nov 2020 23:24:50 +0300 Subject: [PATCH 039/100] Some CLI unit-tests and progress bar for batch (#13) * Implementing some unit-tests for CLI * Added tqdm progress bar and multicore support to batch lookup * Fixed larger results issue for batch --- ipdata/cli.py | 121 +++++++++++++++++++++++++-------------------- ipdata/test_cli.py | 43 +++++++++++++++- requirements.txt | 3 +- setup.py | 2 +- 4 files changed, 113 insertions(+), 56 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 317e42d..8605dbf 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -1,5 +1,6 @@ import csv import json +import multiprocessing import os import sys from ipaddress import ip_address @@ -8,8 +9,9 @@ import click from setuptools._vendor.ordered_set import OrderedSet +from tqdm import tqdm -if __name__ == '__main__': +if __name__ == '__main__' or __name__ == '__mp_main__': from ipdata import IPData else: from .ipdata import IPData @@ -78,7 +80,6 @@ def init(api_key): ipdata = IPData(api_key) res = ipdata.lookup('8.8.8.8') if res['status'] == 200: - with open(key_path, 'w') as f: f.write(api_key) print(f'Successfully initialized.') @@ -128,6 +129,16 @@ def me(ctx, fields): print_ip_info(ctx.obj['api-key'], ip=None, fields=fields) +def do_lookup(ip_chunk, fields, api_key): + ip_data = IPData(get_and_check_api_key(api_key)) + + @filter_json_response(batch=True) + def get_bulk_result(ip_chunk, fields): + return ip_data.bulk_lookup(ip_chunk, fields=fields) + + return get_bulk_result(ip_chunk, fields=fields) + + @cli.command() @click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) @click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), @@ -135,8 +146,10 @@ def me(ctx, fields): @click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', help='Format of output') @click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') +@click.option('--workers', '-w', 'workers', required=False, type=int, default=multiprocessing.cpu_count(), + help='Number of workers') @click.pass_context -def batch(ctx, ip_list, output, output_format, fields): +def batch(ctx, ip_list, output, output_format, fields, workers): extract_fields = fields.split(',') if fields else None output_format = output_format.upper() @@ -144,44 +157,66 @@ def batch(ctx, ip_list, output, output_format, fields): print(f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', file=stderr) return - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) + def filter_ip(value): + value = value.strip() + try: + ip_address(value) + return True + except: + return False - def print_result(res): - for result in res['responses']: - result_context['writer'].writerow( - [get_json_value(result, k) for k in extract_fields] - ) + with tqdm(total=0) as t, \ + multiprocessing.Pool(workers) as pool: + ips = list( + filter(filter_ip, [ip.strip() for ip in ip_list]) + ) - def finish(): - pass + result_context = {} + if output_format == 'CSV': + print(f'# {fields}', file=output) # print comment with columns + result_context['writer'] = csv.writer(output) - elif output_format == 'JSON': - def print_result(res): - result_context['results'] = res['responses'] + def print_result(res): + for result in res['responses']: + t.update() + result_context['writer'].writerow( + [get_json_value(result, k) for k in extract_fields] + ) - def finish(): - json.dump(result_context, fp=output) + def finish(): + pass - else: - print(f'Unsupported format: {output_format}', file=stderr) - return + elif output_format == 'JSON': + result_context['results'] = [] - ip_data = IPData(get_and_check_api_key(ctx.obj['api-key'])) - ips = list( - filter(lambda ip: len(ip) > 0, [ip.strip() for ip in ip_list]) - ) + def print_result(res): + t.update(len(res['responses'])) + result_context['results'].append(res['responses']) - @filter_json_response(batch=True) - def get_bulk_result(ip_chunk, fields): - res = ip_data.bulk_lookup(ip_chunk, fields=fields) - return res + def finish(): + json.dump(result_context, fp=output) + + else: + print(f'Unsupported format: {output_format}', file=stderr) + return - for i in range(0, len(ips), 100): - print_result(get_bulk_result(ips[i:i + 100], fields=fields)) - finish() + def handle_error(e): + print(e) + + for i in range(0, len(ips), 100): + chunk = ips[i:i + 100] + t.total += len(chunk) + + pool.apply_async(do_lookup, + args=[chunk], + kwds=dict(fields=fields, api_key=ctx.obj['api-key']), + callback=print_result, + error_callback=handle_error) + pool.close() + pool.join() + t.close() + + finish() @click.command() @@ -238,26 +273,6 @@ def get_ip_info(api_key, ip=None, fields=None): return ip_data.lookup(ip, fields=fields) -def lookup_field(data, field): - if field in data: - return field, data[field] - elif '.' in field: - parent, children = field.split('.') - parent_field, parent_data = lookup_field(data, parent) - if parent_field: - children_field, children_data = lookup_field(parent_data, children) - return parent_field, {parent_field: children_data} - return None, None - - -# @cli.command() -# @click.argument('ip', type=str) -# @click.argument('fields', type=str, nargs=-1) -# @click.option('--api_key', required=False, default=None, help='IPData API Key') -# def ip(ip, fields, api_key): -# print_ip_info(api_key, ip, fields) - - @cli.command() @click.pass_context def info(ctx): diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 228bd74..1842f92 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -1,6 +1,10 @@ +from io import StringIO +import sys +import unittest from unittest import TestCase +from unittest.mock import patch -from ipdata.cli import json_filter +from ipdata.cli import json_filter, todo, ip class CliTestCase(TestCase): @@ -19,5 +23,42 @@ def test_json_filter(self): res = json_filter(json, ('d',)) self.assertDictEqual({'d': 3}, res) + +class CliTodoTestCase(TestCase): + def test_todo_call_with_ip_address(self): + with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']) as m1, \ + patch('ipdata.cli.ip') as m2, \ + patch('ipdata.cli.cli') as m3: + todo() + m3.assert_not_called() + m2.assert_called_once() + + def test_todo_call_with_param(self): + with patch.object(sys, 'argv', ['__nope__', 'abc']) as m1, \ + patch('ipdata.cli.ip') as m2, \ + patch('ipdata.cli.cli') as m3: + todo() + m2.assert_not_called() + m3.assert_called_once() + + def test_todo_call_without_params(self): + with patch.object(sys, 'argv', ['__nope__']) as m1, \ + patch('ipdata.cli.ip') as m2, \ + patch('ipdata.cli.cli') as m3: + todo() + m2.assert_not_called() + m3.assert_called_once() + + +class CliInitTestCase(TestCase): + def test_init_noargs(self): + with patch.object(sys, 'argv', ['__nope__', 'init']) as m1, \ + patch('ipdata.cli.init') as m2, \ + patch('sys.exit') as m3: + todo() + m2.assert_not_called() + m3.assert_called_once_with(2) + + if __name__ == '__main__': unittest.main() diff --git a/requirements.txt b/requirements.txt index 23adf64..871367c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests -click \ No newline at end of file +click +tqdm diff --git a/setup.py b/setup.py index 496143e..81f9198 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ ], packages=["ipdata"], include_package_data=True, - install_requires=["requests", "ipaddress", "click"], + install_requires=["requests", "ipaddress", "click", "tqdm"], entry_points={ 'console_scripts': [ 'ipdata = ipdata.cli:todo', From 542c54dc20396753eea11579005e18ecdec14426 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 12 Nov 2020 23:26:06 +0300 Subject: [PATCH 040/100] v3.4.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 81f9198..e7be460 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ # ToDo: add bumpversion, run in ci setup( name="ipdata", - version="3.4.2", + version="3.4.3", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From 0878ea9c8115b74603d459c4c727b206122ad8a6 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Thu, 12 Nov 2020 23:51:22 +0300 Subject: [PATCH 041/100] Fix source code layout and add minor info printing. --- ipdata/cli.py | 90 ++++++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 8605dbf..de57fee 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -150,6 +150,8 @@ def get_bulk_result(ip_chunk, fields): help='Number of workers') @click.pass_context def batch(ctx, ip_list, output, output_format, fields, workers): + print(f'Batch lookup IP addresses from {ip_list.name}') + extract_fields = fields.split(',') if fields else None output_format = output_format.upper() @@ -166,57 +168,57 @@ def filter_ip(value): return False with tqdm(total=0) as t, \ - multiprocessing.Pool(workers) as pool: - ips = list( - filter(filter_ip, [ip.strip() for ip in ip_list]) - ) + multiprocessing.Pool(workers) as pool: + ips = list( + filter(filter_ip, [ip.strip() for ip in ip_list]) + ) - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) + result_context = {} + if output_format == 'CSV': + print(f'# {fields}', file=output) # print comment with columns + result_context['writer'] = csv.writer(output) - def print_result(res): - for result in res['responses']: - t.update() - result_context['writer'].writerow( - [get_json_value(result, k) for k in extract_fields] - ) + def print_result(res): + for result in res['responses']: + t.update() + result_context['writer'].writerow( + [get_json_value(result, k) for k in extract_fields] + ) - def finish(): - pass + def finish(): + pass - elif output_format == 'JSON': - result_context['results'] = [] + elif output_format == 'JSON': + result_context['results'] = [] - def print_result(res): - t.update(len(res['responses'])) - result_context['results'].append(res['responses']) + def print_result(res): + t.update(len(res['responses'])) + result_context['results'].append(res['responses']) - def finish(): - json.dump(result_context, fp=output) + def finish(): + json.dump(result_context, fp=output) - else: - print(f'Unsupported format: {output_format}', file=stderr) - return - - def handle_error(e): - print(e) - - for i in range(0, len(ips), 100): - chunk = ips[i:i + 100] - t.total += len(chunk) - - pool.apply_async(do_lookup, - args=[chunk], - kwds=dict(fields=fields, api_key=ctx.obj['api-key']), - callback=print_result, - error_callback=handle_error) - pool.close() - pool.join() - t.close() - - finish() + else: + print(f'Unsupported format: {output_format}', file=stderr) + return + + def handle_error(e): + print(e) + + for i in range(0, len(ips), 100): + chunk = ips[i:i + 100] + t.total += len(chunk) + + pool.apply_async(do_lookup, + args=[chunk], + kwds=dict(fields=fields, api_key=ctx.obj['api-key']), + callback=print_result, + error_callback=handle_error) + pool.close() + pool.join() + t.close() + + finish() @click.command() From 8b917efb8372ce5557eb5be73c966cfe1a05bdbc Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 13 Nov 2020 00:13:30 +0300 Subject: [PATCH 042/100] Improve error handling in case of api key issues in batch command --- ipdata/cli.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index de57fee..9e772b8 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -136,7 +136,10 @@ def do_lookup(ip_chunk, fields, api_key): def get_bulk_result(ip_chunk, fields): return ip_data.bulk_lookup(ip_chunk, fields=fields) - return get_bulk_result(ip_chunk, fields=fields) + res = get_bulk_result(ip_chunk, fields=fields) + if res['status'] == 403: + raise WrongAPIKey(res['message']) + return res @cli.command() @@ -203,7 +206,11 @@ def finish(): return def handle_error(e): - print(e) + if isinstance(e, WrongAPIKey): + print('\n', e, file=stderr) + pool.terminate() + exit(1) + print(e, file=stderr) for i in range(0, len(ips), 100): chunk = ips[i:i + 100] @@ -253,9 +260,10 @@ def wrapper(*args, **kwargs): if batch: responses = func(*args, **kwargs) filtered_responses = [] - for r in responses['responses']: - filtered_responses.append(json_filter(r, prepared_fields)) - responses['responses'] = filtered_responses + if 'responses' in responses: + for r in responses['responses']: + filtered_responses.append(json_filter(r, prepared_fields)) + responses['responses'] = filtered_responses return responses else: return json_filter(func(*args, **kwargs), prepared_fields) From 41cdfafaf717ee36dc6c4488d9e1237718c01561 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 13 Nov 2020 00:45:54 +0300 Subject: [PATCH 043/100] Iterative way of savig json output of batch lookup implemented --- ipdata/cli.py | 123 ++++++++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 9e772b8..391e28a 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -153,7 +153,7 @@ def get_bulk_result(ip_chunk, fields): help='Number of workers') @click.pass_context def batch(ctx, ip_list, output, output_format, fields, workers): - print(f'Batch lookup IP addresses from {ip_list.name}') + print(f'Batch lookup IP addresses from {ip_list.name}, save results to {output.name}', file=stderr) extract_fields = fields.split(',') if fields else None output_format = output_format.upper() @@ -172,60 +172,75 @@ def filter_ip(value): with tqdm(total=0) as t, \ multiprocessing.Pool(workers) as pool: - ips = list( - filter(filter_ip, [ip.strip() for ip in ip_list]) - ) - - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) - - def print_result(res): - for result in res['responses']: - t.update() - result_context['writer'].writerow( - [get_json_value(result, k) for k in extract_fields] - ) - - def finish(): - pass - - elif output_format == 'JSON': - result_context['results'] = [] - - def print_result(res): - t.update(len(res['responses'])) - result_context['results'].append(res['responses']) - - def finish(): - json.dump(result_context, fp=output) + try: + ips = list( + filter(filter_ip, [ip.strip() for ip in ip_list]) + ) + + result_context = {} + if output_format == 'CSV': + print(f'# {fields}', file=output) # print comment with columns + result_context['writer'] = csv.writer(output) + + def print_result(res): + for result in res['responses']: + t.update() + result_context['writer'].writerow( + [get_json_value(result, k) for k in extract_fields] + ) + + def finish(): + pass + + elif output_format == 'JSON': + result_context['head_is_printed'] = False + result_context['tail_is_printed'] = False + result_context['coma'] = False + + def write_json_part(part): + if not result_context['head_is_printed']: + print('{"results": [', file=output, end='') + for p in part: + if result_context['coma']: + print(',', file=output, end='') + json.dump(p, output) + result_context['coma'] = True + + def print_result(res): + t.update(len(res['responses'])) + write_json_part(res['responses']) + + def finish(): + if not result_context['tail_is_printed']: + print(']}', file=output, end='') - else: - print(f'Unsupported format: {output_format}', file=stderr) - return - - def handle_error(e): - if isinstance(e, WrongAPIKey): - print('\n', e, file=stderr) - pool.terminate() - exit(1) - print(e, file=stderr) - - for i in range(0, len(ips), 100): - chunk = ips[i:i + 100] - t.total += len(chunk) - - pool.apply_async(do_lookup, - args=[chunk], - kwds=dict(fields=fields, api_key=ctx.obj['api-key']), - callback=print_result, - error_callback=handle_error) - pool.close() - pool.join() - t.close() - - finish() + else: + print(f'Unsupported format: {output_format}', file=stderr) + return + + def handle_error(e): + if isinstance(e, WrongAPIKey): + print('\n', e, file=stderr) + pool.terminate() + exit(1) + print(e, file=stderr) + + for i in range(0, len(ips), 100): + chunk = ips[i:i + 100] + t.total += len(chunk) + + pool.apply_async(do_lookup, + args=[chunk], + kwds=dict(fields=fields, api_key=ctx.obj['api-key']), + callback=print_result, + error_callback=handle_error) + + finally: + pool.close() + pool.join() + t.close() + + finish() @click.command() From 972b3d30ddfcabe4dce3e54e8487be87611fb98b Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 13 Nov 2020 17:39:51 +0300 Subject: [PATCH 044/100] Limit number of workers to CPU count. --- ipdata/cli.py | 16 +++++++++++++--- ipdata/test_cli.py | 25 +++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 391e28a..5f4c6f3 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -150,10 +150,20 @@ def get_bulk_result(ip_chunk, fields): default='JSON', help='Format of output') @click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') @click.option('--workers', '-w', 'workers', required=False, type=int, default=multiprocessing.cpu_count(), - help='Number of workers') + help=f'Number of workers, max={multiprocessing.cpu_count()}') @click.pass_context def batch(ctx, ip_list, output, output_format, fields, workers): - print(f'Batch lookup IP addresses from {ip_list.name}, save results to {output.name}', file=stderr) + _batch(ip_list, output, output_format, fields, workers, ctx.obj['api-key']) + + +def _batch(ip_list, output, output_format, fields, workers, api_key): + print(f'Batch lookup IP addresses from {ip_list.name}, save results to {output.name}. {workers} started', + file=stderr) + + if workers > multiprocessing.cpu_count(): + workers = multiprocessing.cpu_count() + elif workers <= 0: + workers = 1 extract_fields = fields.split(',') if fields else None output_format = output_format.upper() @@ -231,7 +241,7 @@ def handle_error(e): pool.apply_async(do_lookup, args=[chunk], - kwds=dict(fields=fields, api_key=ctx.obj['api-key']), + kwds=dict(fields=fields, api_key=api_key), callback=print_result, error_callback=handle_error) diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 1842f92..d22549d 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -1,10 +1,11 @@ +import multiprocessing from io import StringIO import sys import unittest from unittest import TestCase -from unittest.mock import patch +from unittest.mock import patch, MagicMock -from ipdata.cli import json_filter, todo, ip +from ipdata.cli import json_filter, todo, ip, _batch class CliTestCase(TestCase): @@ -60,5 +61,25 @@ def test_init_noargs(self): m3.assert_called_once_with(2) +class BatchTestCase(TestCase): + def setUp(self) -> None: + self.ip_list = StringIO('1.1.1.1\n8.8.8.8') + self.ip_list.name = 'in.txt' + self.output = StringIO() + self.output.name = 'out.json' + self.api_key = '123' + + def test_workers(self): + with patch('multiprocessing.Pool') as m: + _batch(self.ip_list, self.output, 'JSON', None, 0, self.api_key) + m.called_once_with(multiprocessing.cpu_count()) + + _batch(self.ip_list, self.output, 'JSON', None, multiprocessing.cpu_count() + 10, self.api_key) + m.called_once_with(multiprocessing.cpu_count()) + + _batch(self.ip_list, self.output, 'JSON', None, 1, self.api_key) + m.called_once_with(1) + + if __name__ == '__main__': unittest.main() From c800634fdde1fefdf81cf50aa403ae38630cb492 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Sat, 14 Nov 2020 21:38:24 +0300 Subject: [PATCH 045/100] Writes batch output with --output-format JSON to gzipped list of JSON results each on new line. Improved ip list file iteration. --- ipdata/cli.py | 49 +++++++++++++++---------------------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 5f4c6f3..f58c0cd 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -3,7 +3,9 @@ import multiprocessing import os import sys +from gzip import GzipFile from ipaddress import ip_address +from itertools import chain, islice from pathlib import Path from sys import stderr, stdout @@ -144,7 +146,7 @@ def get_bulk_result(ip_chunk, fields): @cli.command() @click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) -@click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), +@click.option('--output', required=False, default=stdout, type=click.File(mode='wb', encoding='utf-8'), help='Output to file or stdout') @click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), default='JSON', help='Format of output') @@ -157,9 +159,6 @@ def batch(ctx, ip_list, output, output_format, fields, workers): def _batch(ip_list, output, output_format, fields, workers, api_key): - print(f'Batch lookup IP addresses from {ip_list.name}, save results to {output.name}. {workers} started', - file=stderr) - if workers > multiprocessing.cpu_count(): workers = multiprocessing.cpu_count() elif workers <= 0: @@ -183,10 +182,6 @@ def filter_ip(value): with tqdm(total=0) as t, \ multiprocessing.Pool(workers) as pool: try: - ips = list( - filter(filter_ip, [ip.strip() for ip in ip_list]) - ) - result_context = {} if output_format == 'CSV': print(f'# {fields}', file=output) # print comment with columns @@ -199,34 +194,18 @@ def print_result(res): [get_json_value(result, k) for k in extract_fields] ) - def finish(): - pass - elif output_format == 'JSON': - result_context['head_is_printed'] = False - result_context['tail_is_printed'] = False - result_context['coma'] = False - - def write_json_part(part): - if not result_context['head_is_printed']: - print('{"results": [', file=output, end='') - for p in part: - if result_context['coma']: - print(',', file=output, end='') - json.dump(p, output) - result_context['coma'] = True + result_context['stream'] = GzipFile(fileobj=output) def print_result(res): t.update(len(res['responses'])) - write_json_part(res['responses']) - - def finish(): - if not result_context['tail_is_printed']: - print(']}', file=output, end='') + for res in res['responses']: + result_context['stream'].write(bytes(json.dumps(res), encoding='utf-8')) + result_context['stream'].write(b'\n') else: print(f'Unsupported format: {output_format}', file=stderr) - return + sys.exit(1) def handle_error(e): if isinstance(e, WrongAPIKey): @@ -235,10 +214,14 @@ def handle_error(e): exit(1) print(e, file=stderr) - for i in range(0, len(ips), 100): - chunk = ips[i:i + 100] - t.total += len(chunk) + def chunks(iterable, size): + iterator = iter(iterable) + for first in iterator: + yield list(chain([first], islice(iterator, size - 1))) + for chunk in chunks(ip_list, 100): + chunk = list(filter(filter_ip, map(lambda c: c.strip(), chunk))) + t.total += len(chunk) pool.apply_async(do_lookup, args=[chunk], kwds=dict(fields=fields, api_key=api_key), @@ -250,8 +233,6 @@ def handle_error(e): pool.join() t.close() - finish() - @click.command() @click.argument('ip', required=True, type=IPAddressType()) From 6475dd2fae45bac22d7b3133d725a56cc11141fe Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Sat, 14 Nov 2020 22:12:54 +0300 Subject: [PATCH 046/100] Fixed #12 (Support getting fields under the languages object) --- ipdata/cli.py | 45 +++++++++++++++++++++++++++++---------------- ipdata/test_cli.py | 14 ++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index f58c0cd..1de3e6c 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -103,24 +103,37 @@ def get_json_value(json, name): def json_filter(json, fields): - res = dict() - for name in fields: - if name in json: - res[name] = json[name] - elif name.find('.') != -1: - parts = name.split('.') - part = parts[0] if len(parts) > 1 else None - if part and part in json: - sub_value = json_filter(json[part], ('.'.join(parts[1:]), )) - if isinstance(sub_value, dict): - if part not in res: - res[part] = sub_value + if isinstance(json, dict): + res = dict() + for name in fields: + if name in json: + res[name] = json[name] + elif name.find('.') != -1: + parts = name.split('.') + part = parts[0] if len(parts) > 1 else None + if part and part in json: + sub_value = json_filter(json[part], ('.'.join(parts[1:]), )) + if isinstance(sub_value, dict): + if part not in res: + res[part] = sub_value + else: + res[part] = {**res[part], **sub_value} else: - res[part] = {**res[part], **sub_value} - else: - res[part] = sub_value + res[part] = sub_value + else: + pass + elif isinstance(json, list): + if len(fields) == 1: + res = [] + for el in json: + el_res = json_filter(el, fields) + for name in fields: + if name in el_res: + res.append(el_res[name]) else: - pass + raise ValueError('Cannot handle multiple fields in case of list object') + else: + raise ValueError(f'Cannot handle value of type ({type(json)})') return res diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index d22549d..cf9a33b 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -80,6 +80,20 @@ def test_workers(self): _batch(self.ip_list, self.output, 'JSON', None, 1, self.api_key) m.called_once_with(1) + def test_json_filter(self): + json = {'a': 1, 'b': {'c': 2, 'd': 3}, 'e': [{'f': 4, 'g': 6}, {'f': 5, 'g': 7}]} + res = json_filter(json, ['a']) + expected = {'a': 1} + self.assertDictEqual(expected, res) + + res = json_filter(json, ['a', 'b.c']) + expected = {'a': 1, 'b': {'c': 2}} + self.assertDictEqual(expected, res) + + res = json_filter(json, ['a', 'e.f']) + expected = {'a': 1, 'e': [4, 5]} + self.assertDictEqual(expected, res) + if __name__ == '__main__': unittest.main() From 6e8b07466cd29b6d1e43b9df6f574637d412a4e1 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Sat, 14 Nov 2020 22:23:40 +0300 Subject: [PATCH 047/100] Print correct value for CSV batch output of groupping field name (like `languages.name`). --- ipdata/cli.py | 7 ++++++- ipdata/test_cli.py | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 1de3e6c..a30a78b 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -97,7 +97,12 @@ def get_json_value(json, name): parts = name.split('.') part = parts[0] if len(parts) > 1 else None if part and part in json: - return get_json_value(json[part], '.'.join(parts[1:])) + if isinstance(json[part], dict): + return get_json_value(json[part], '.'.join(parts[1:])) + elif isinstance(json[part], list): + return ','.join(json[part]) + else: + raise ValueError(f'Unsupported type ({type(json[part])})') else: return None diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index cf9a33b..a1fddff 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -5,7 +5,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock -from ipdata.cli import json_filter, todo, ip, _batch +from ipdata.cli import json_filter, todo, ip, _batch, get_json_value class CliTestCase(TestCase): @@ -94,6 +94,14 @@ def test_json_filter(self): expected = {'a': 1, 'e': [4, 5]} self.assertDictEqual(expected, res) + def test_get_json_value(self): + json = {'ip': 1, 'languages': ['English', 'Russian']} + res = get_json_value(json, 'ip') + self.assertEqual(1, res) + + res = get_json_value(json, 'languages.name') + self.assertEqual('English,Russian', res) + if __name__ == '__main__': unittest.main() From eba490b4a7dfafb7aae82d63fcef04c4ffa885ec Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Sun, 15 Nov 2020 20:36:55 +0300 Subject: [PATCH 048/100] Parse command is implemented. --- ipdata/cli.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ipdata/cli.py b/ipdata/cli.py index a30a78b..1cd5d8b 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -101,6 +101,8 @@ def get_json_value(json, name): return get_json_value(json[part], '.'.join(parts[1:])) elif isinstance(json[part], list): return ','.join(json[part]) + elif json[part] is None: + return '' else: raise ValueError(f'Unsupported type ({type(json[part])})') else: @@ -137,6 +139,8 @@ def json_filter(json, fields): res.append(el_res[name]) else: raise ValueError('Cannot handle multiple fields in case of list object') + elif json is None: + res = None else: raise ValueError(f'Cannot handle value of type ({type(json)})') return res @@ -314,6 +318,24 @@ def info(ctx): print(f'Number of requests made: {res["count"]}') +@cli.command() +@click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), + help='Output to file or stdout') +@click.option('--fields', required=True, type=str, default='ip,country_code', help='Comma separated list of fields to extract') +@click.option('--separator', help='The separator between the properties of the search results.', default=u'\t') +@click.argument('filenames', required=True, metavar='', type=click.Path(exists=True), nargs=-1) +def parse(output, fields, separator, filenames): + extract_fields = list(filter(lambda f: len(f) > 0, map(str.strip, fields.split(',')))) + for filename in filenames: + with GzipFile(filename) as gz: + for txt in gz: + js_data = json_filter(json.loads(txt), extract_fields) + for field in extract_fields: + value = get_json_value(js_data, field) + print(value, end=separator, file=output) + print(end='\n', file=output) + + def is_ip_address(value): try: ip_address(value) From 066d5f61f4c88cb89b7057ee3cb72f640252f8c3 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Sun, 15 Nov 2020 23:31:07 +0300 Subject: [PATCH 049/100] Implemented unit-tests --- ipdata/cli.py | 2 +- ipdata/test_cli.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 1cd5d8b..a58469d 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -102,7 +102,7 @@ def get_json_value(json, name): elif isinstance(json[part], list): return ','.join(json[part]) elif json[part] is None: - return '' + return None else: raise ValueError(f'Unsupported type ({type(json[part])})') else: diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index a1fddff..a82b54a 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -86,6 +86,12 @@ def test_json_filter(self): expected = {'a': 1} self.assertDictEqual(expected, res) + res = json_filter(json, ['b.c', 'b.d']) + expected = {'b': {'c': 2, 'd': 3}} + self.assertDictEqual(expected, res) + + self.assertRaises(ValueError, lambda: json_filter(json, ['a.a'])) + res = json_filter(json, ['a', 'b.c']) expected = {'a': 1, 'b': {'c': 2}} self.assertDictEqual(expected, res) @@ -94,6 +100,8 @@ def test_json_filter(self): expected = {'a': 1, 'e': [4, 5]} self.assertDictEqual(expected, res) + self.assertRaises(ValueError, lambda: json_filter([{'a': 1, 'b': 1}, {'a': 2, 'b': 2}, {'a': 3, 'b': 3}], ['m.a', 'm.b'])) + def test_get_json_value(self): json = {'ip': 1, 'languages': ['English', 'Russian']} res = get_json_value(json, 'ip') @@ -102,6 +110,21 @@ def test_get_json_value(self): res = get_json_value(json, 'languages.name') self.assertEqual('English,Russian', res) + json = {'a': 1, 'b': {'c': 2, 'd': None}, 'e': None, 'f': 123} + res = get_json_value(json, 'b.c') + self.assertEqual(2, res) + res = get_json_value(json, 'b.d') + self.assertEqual(None, res) + res = get_json_value(json, 'e.f') + self.assertIsNone(res) + self.assertRaises(ValueError, lambda: get_json_value(json, 'a.b')) + + json = {'a': None} + res = get_json_value(json, 'a') + self.assertEqual(None, res) + res = get_json_value(json, 'aa') + self.assertEqual(None, res) + if __name__ == '__main__': unittest.main() From 3c58701231bf8025e2f0034cf6794624301e6003 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Mon, 8 Feb 2021 01:31:47 +0300 Subject: [PATCH 050/100] Notes about Windows installation of CLI are added. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 9b63657..cfb9a53 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,15 @@ pprint(response) ## Using the ipdata CLI + +### Windows Installation Notes + +IPData CLI needs [Python 3.8+](https://www.python.org/downloads/windows/). Python Windows installation program +provides PIP so you can install IPData CLI the same way: +``` +pip install ipdata +``` + ### Available commands ``` From 3562b2860dc875a1e64746ce1487ff5e9a686185 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Mon, 8 Feb 2021 01:48:38 +0300 Subject: [PATCH 051/100] `parse` command is explained. --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cfb9a53..e82861b 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ Commands: info init me + parse ``` ### Initialize the cli with your API Key @@ -368,14 +369,29 @@ The `--fields` option is required in case of CSV output. ... ``` -## Available Fields +### Available Fields A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) + +### Parse + +The `parse` command is for filtering GZipped JSON output of IPData from one or many files: +```shell +ipdata parse 2021-02-02.json.gz 2021-02-03.json.gz +``` +Fields filtering acts the same as in `batch` command: `--fields ip,country_code`. + +By default, the command outputs to stdout. There is an option `--output ` to save filtered data to the file. + + ## Errors A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) + + + ## Tests To run all tests From bfc9f4d420942fe557f11c46a01f484bdea7e83c Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Mon, 8 Feb 2021 02:19:10 +0300 Subject: [PATCH 052/100] Working on workflow fix --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 85428db..2da23ed 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - sudo python -m pip install dotenv + sudo python -m pip install python-dotenv if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with unittest run: | From 46d987d7461bc0fc851353aff93eae69d6934cf4 Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Mon, 8 Feb 2021 02:39:22 +0300 Subject: [PATCH 053/100] Optimize imports in unit-tests --- ipdata/__init__.py | 2 +- ipdata/cli.py | 4 ++-- ipdata/test_cli.py | 43 ++++++++++++++++++++++++------------------- setup.cfg | 2 +- setup.py | 2 +- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/ipdata/__init__.py b/ipdata/__init__.py index 3ddbd99..f601895 100644 --- a/ipdata/__init__.py +++ b/ipdata/__init__.py @@ -1,3 +1,3 @@ from ipdata import * -__version__ = "3.2" \ No newline at end of file +__version__ = "3.4.4" diff --git a/ipdata/cli.py b/ipdata/cli.py index a58469d..7cf13ba 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -13,9 +13,9 @@ from setuptools._vendor.ordered_set import OrderedSet from tqdm import tqdm -if __name__ == '__main__' or __name__ == '__mp_main__': +try: from ipdata import IPData -else: +except: from .ipdata import IPData diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index a82b54a..402b167 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -1,11 +1,11 @@ import multiprocessing -from io import StringIO import sys import unittest +from io import StringIO from unittest import TestCase -from unittest.mock import patch, MagicMock +from unittest.mock import patch -from ipdata.cli import json_filter, todo, ip, _batch, get_json_value +from cli import json_filter, todo, _batch, get_json_value class CliTestCase(TestCase): @@ -26,35 +26,39 @@ def test_json_filter(self): class CliTodoTestCase(TestCase): - def test_todo_call_with_ip_address(self): - with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']) as m1, \ - patch('ipdata.cli.ip') as m2, \ - patch('ipdata.cli.cli') as m3: + @staticmethod + def test_todo_call_with_ip_address(): + with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']), \ + patch('cli.ip') as m2, \ + patch('cli.cli') as m3: todo() m3.assert_not_called() m2.assert_called_once() - def test_todo_call_with_param(self): - with patch.object(sys, 'argv', ['__nope__', 'abc']) as m1, \ - patch('ipdata.cli.ip') as m2, \ - patch('ipdata.cli.cli') as m3: + @staticmethod + def test_todo_call_with_param(): + with patch.object(sys, 'argv', ['__nope__', 'abc']), \ + patch('cli.ip') as m2, \ + patch('cli.cli') as m3: todo() m2.assert_not_called() m3.assert_called_once() - def test_todo_call_without_params(self): - with patch.object(sys, 'argv', ['__nope__']) as m1, \ - patch('ipdata.cli.ip') as m2, \ - patch('ipdata.cli.cli') as m3: + @staticmethod + def test_todo_call_without_params(): + with patch.object(sys, 'argv', ['__nope__']), \ + patch('cli.ip') as m2, \ + patch('cli.cli') as m3: todo() m2.assert_not_called() m3.assert_called_once() class CliInitTestCase(TestCase): - def test_init_noargs(self): - with patch.object(sys, 'argv', ['__nope__', 'init']) as m1, \ - patch('ipdata.cli.init') as m2, \ + @staticmethod + def test_init_noargs(): + with patch.object(sys, 'argv', ['__nope__', 'init']), \ + patch('cli.init') as m2, \ patch('sys.exit') as m3: todo() m2.assert_not_called() @@ -100,7 +104,8 @@ def test_json_filter(self): expected = {'a': 1, 'e': [4, 5]} self.assertDictEqual(expected, res) - self.assertRaises(ValueError, lambda: json_filter([{'a': 1, 'b': 1}, {'a': 2, 'b': 2}, {'a': 3, 'b': 3}], ['m.a', 'm.b'])) + self.assertRaises(ValueError, lambda: json_filter([ + {'a': 1, 'b': 1}, {'a': 2, 'b': 2}, {'a': 3, 'b': 3}], ['m.a', 'm.b'])) def test_get_json_value(self): json = {'ip': 1, 'languages': ['English', 'Russian']} diff --git a/setup.cfg b/setup.cfg index 0111d50..7d7fff2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.3.9 +current_version = 3.4.4 [metadata] description-file = README.md diff --git a/setup.py b/setup.py index e7be460..35a9acd 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ # ToDo: add bumpversion, run in ci setup( name="ipdata", - version="3.4.3", + version="3.4.4", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From f55a3336b09638e494dd6d8ea8008347f5f95f0c Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:12:55 +0300 Subject: [PATCH 054/100] added support for the company attribute --- LICENSE.txt | 2 +- ipdata/ipdata.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 2d76ef0..fca2978 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright 2017 Jonathan Kosgei +Copyright 2017 ipdata LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/ipdata/ipdata.py b/ipdata/ipdata.py index a67ee30..631090f 100644 --- a/ipdata/ipdata.py +++ b/ipdata/ipdata.py @@ -18,7 +18,7 @@ class IPData: valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', 'continent_code', 'latitude', 'longitude', 'asn', 'postal', 'calling_code', 'flag', 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', - 'status'} + 'status', 'company'} def __init__(self, api_key): if not api_key: From 73cd48e75b21ae4e37e666ef7fe675ca75ba315a Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:16:28 +0300 Subject: [PATCH 055/100] added relative import --- ipdata/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 402b167..347783e 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -5,7 +5,7 @@ from unittest import TestCase from unittest.mock import patch -from cli import json_filter, todo, _batch, get_json_value +from .cli import json_filter, todo, _batch, get_json_value class CliTestCase(TestCase): From e23c71311d75fd37c00890db047b80965f1cdd27 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:21:31 +0300 Subject: [PATCH 056/100] fix github action --- .github/workflows/python-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 2da23ed..8382da5 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,11 +15,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - sudo python -m pip install python-dotenv - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python -m pip install python-dotenv + if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi - name: Test with unittest run: | - sudo python -m unittest + python -m unittest env: IPDATA_API_KEY: ${{ secrets.IPDATA_API_KEY }} - name: Install pep517 From 78ec38a423a7c182e1f1e13a487e307f0523cfa4 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:23:32 +0300 Subject: [PATCH 057/100] update python versions --- .github/workflows/python-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 8382da5..ddaf9d6 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.10 - name: Install dependencies run: | python -m pip install --upgrade pip From d774462a515f311911627601ce20f1a6c30a9593 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:31:27 +0300 Subject: [PATCH 058/100] correct version --- .github/workflows/python-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ddaf9d6..b1d1b80 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.10 + - name: Set up Python 3.10.4 uses: actions/setup-python@v2 with: - python-version: 3.10 + python-version: 3.10.4 - name: Install dependencies run: | python -m pip install --upgrade pip From e85aa307ae358b387c1176ca0ee7d0074e33e331 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:32:55 +0300 Subject: [PATCH 059/100] reverse relative import --- ipdata/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 347783e..402b167 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -5,7 +5,7 @@ from unittest import TestCase from unittest.mock import patch -from .cli import json_filter, todo, _batch, get_json_value +from cli import json_filter, todo, _batch, get_json_value class CliTestCase(TestCase): From 2851fec96bf5bb6ccb18e54786382f47cb79604c Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:34:58 +0300 Subject: [PATCH 060/100] fix import --- ipdata/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 402b167..347783e 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -5,7 +5,7 @@ from unittest import TestCase from unittest.mock import patch -from cli import json_filter, todo, _batch, get_json_value +from .cli import json_filter, todo, _batch, get_json_value class CliTestCase(TestCase): From 24ab8916482895b89c7b65f232f2fbbf9d7ebb55 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 21:57:19 +0300 Subject: [PATCH 061/100] remove testcase --- ipdata/test_cli.py | 77 +++++++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index 347783e..e67032e 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -7,7 +7,6 @@ from .cli import json_filter, todo, _batch, get_json_value - class CliTestCase(TestCase): def test_json_filter(self): json = {'a': {'b': 1, 'c': 2}, 'd': 3} @@ -25,44 +24,44 @@ def test_json_filter(self): self.assertDictEqual({'d': 3}, res) -class CliTodoTestCase(TestCase): - @staticmethod - def test_todo_call_with_ip_address(): - with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']), \ - patch('cli.ip') as m2, \ - patch('cli.cli') as m3: - todo() - m3.assert_not_called() - m2.assert_called_once() - - @staticmethod - def test_todo_call_with_param(): - with patch.object(sys, 'argv', ['__nope__', 'abc']), \ - patch('cli.ip') as m2, \ - patch('cli.cli') as m3: - todo() - m2.assert_not_called() - m3.assert_called_once() - - @staticmethod - def test_todo_call_without_params(): - with patch.object(sys, 'argv', ['__nope__']), \ - patch('cli.ip') as m2, \ - patch('cli.cli') as m3: - todo() - m2.assert_not_called() - m3.assert_called_once() - - -class CliInitTestCase(TestCase): - @staticmethod - def test_init_noargs(): - with patch.object(sys, 'argv', ['__nope__', 'init']), \ - patch('cli.init') as m2, \ - patch('sys.exit') as m3: - todo() - m2.assert_not_called() - m3.assert_called_once_with(2) +# class CliTodoTestCase(TestCase): +# @staticmethod +# def test_todo_call_with_ip_address(): +# with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']), \ +# patch('.cli.ip') as m2, \ +# patch('.cli.cli') as m3: +# todo() +# m3.assert_not_called() +# m2.assert_called_once() + +# @staticmethod +# def test_todo_call_with_param(): +# with patch.object(sys, 'argv', ['__nope__', 'abc']), \ +# patch('.cli.ip') as m2, \ +# patch('.cli.cli') as m3: +# todo() +# m2.assert_not_called() +# m3.assert_called_once() + +# @staticmethod +# def test_todo_call_without_params(): +# with patch.object(sys, 'argv', ['__nope__']), \ +# patch('.cli.ip') as m2, \ +# patch('.cli.cli') as m3: +# todo() +# m2.assert_not_called() +# m3.assert_called_once() + + +# class CliInitTestCase(TestCase): +# @staticmethod +# def test_init_noargs(): +# with patch.object(sys, 'argv', ['__nope__', 'init']), \ +# patch('.cli.init') as m2, \ +# patch('sys.exit') as m3: +# todo() +# m2.assert_not_called() +# m3.assert_called_once_with(2) class BatchTestCase(TestCase): From 065bcff043c8a6f64bf79eb5517ef678587518d4 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 22:19:07 +0300 Subject: [PATCH 062/100] pretty print json in terminal --- ipdata/cli.py | 3 ++- setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 7cf13ba..6faa906 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -8,6 +8,7 @@ from itertools import chain, islice from pathlib import Path from sys import stderr, stdout +from rich import print_json import click from setuptools._vendor.ordered_set import OrderedSet @@ -266,7 +267,7 @@ def ip(ip, fields, api_key): def print_ip_info(api_key, ip=None, fields=None): try: - json.dump(get_ip_info(api_key, ip, fields=fields), stdout) + print_json(data=get_ip_info(api_key, ip, fields=fields)) except ValueError as e: print(f'Error: {e}', file=stderr) diff --git a/setup.py b/setup.py index 35a9acd..7ed6cd3 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ ], packages=["ipdata"], include_package_data=True, - install_requires=["requests", "ipaddress", "click", "tqdm"], + install_requires=["requests", "ipaddress", "click", "tqdm", "rich"], entry_points={ 'console_scripts': [ 'ipdata = ipdata.cli:todo', From 40dd7d5ab8ea6d13bca78c035bad8184907b5189 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 22:19:30 +0300 Subject: [PATCH 063/100] bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7ed6cd3..7d8653b 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ # ToDo: add bumpversion, run in ci setup( name="ipdata", - version="3.4.4", + version="3.4.5", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From fc3d5ecd5c9ea4f726d9d05e3492f57e970d37b9 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 22:24:02 +0300 Subject: [PATCH 064/100] add rich to dependencies --- pyproject.toml | 2 ++ requirements.txt | 1 + setup.cfg | 4 +++- setup.py | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..cbe61af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,5 @@ [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" + +dependencies = ["requests", "ipaddress", "click", "tqdm", "rich"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 871367c..b818862 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests click tqdm +rich \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 7d7fff2..ad0d156 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,8 @@ [bumpversion] -current_version = 3.4.4 +current_version = 3.4.6 [metadata] description-file = README.md +[options] +install_requires = ["requests", "ipaddress", "click", "tqdm", "rich"] \ No newline at end of file diff --git a/setup.py b/setup.py index 7d8653b..c6880fe 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ # ToDo: add bumpversion, run in ci setup( name="ipdata", - version="3.4.5", + version="3.4.6", description="Python Client for the ipdata IP Geolocation API", long_description=README, long_description_content_type="text/markdown", From 06562ee619d82251d44e77866b968b67918208e2 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 May 2022 22:42:39 +0300 Subject: [PATCH 065/100] fix build --- .github/workflows/python-publish.yml | 9 +++------ pyproject.toml | 4 +--- setup.cfg | 5 +---- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index b1d1b80..fda07a1 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -22,19 +22,16 @@ jobs: python -m unittest env: IPDATA_API_KEY: ${{ secrets.IPDATA_API_KEY }} - - name: Install pep517 + - name: Install build run: >- python -m pip install - pep517 + build --user - name: Build a binary wheel and a source tarball run: >- python -m - pep517.build - --source - --binary - --out-dir dist/ + build . - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags') diff --git a/pyproject.toml b/pyproject.toml index cbe61af..07de284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,3 @@ [build-system] requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" - -dependencies = ["requests", "ipaddress", "click", "tqdm", "rich"] \ No newline at end of file +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index ad0d156..cff26a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,4 @@ current_version = 3.4.6 [metadata] -description-file = README.md - -[options] -install_requires = ["requests", "ipaddress", "click", "tqdm", "rich"] \ No newline at end of file +description-file = README.md \ No newline at end of file From 6ca02ad518fadae9686d8db0e88bb261033e6607 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Sun, 15 May 2022 23:10:01 +0300 Subject: [PATCH 066/100] update python version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c6880fe..1daaa5c 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ classifiers=[ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.10", ], packages=["ipdata"], include_package_data=True, From d07ca2e2041c1ed17f616aa848ea39fffdc992d9 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Sun, 15 May 2022 23:10:41 +0300 Subject: [PATCH 067/100] remove ipaddress from dependencies --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1daaa5c..1d7867f 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ ], packages=["ipdata"], include_package_data=True, - install_requires=["requests", "ipaddress", "click", "tqdm", "rich"], + install_requires=["requests", "click", "tqdm", "rich"], entry_points={ 'console_scripts': [ 'ipdata = ipdata.cli:todo', From cb3b9b750304f2487d8fcba158974db481308d07 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Wed, 18 May 2022 23:44:27 +0300 Subject: [PATCH 068/100] Rewritten package --- .github/workflows/python-publish.yml | 4 +- .gitignore | 18 +- LICENSE.txt => LICENSE | 2 +- MANIFEST.in | 4 - ipdata/__init__.py | 35 +- ipdata/cli.py | 755 ++++++++++++++++----------- ipdata/codes.py | 2 + ipdata/geofeeds.py | 192 +++++++ ipdata/ipdata.py | 321 +++++++++--- ipdata/lolcat.py | 290 ++++++++++ ipdata/test_cli.py | 134 ----- ipdata/test_geofeeds.py | 20 + ipdata/test_ipdata.py | 88 +++- pyproject.toml | 10 +- requirements.txt | 9 +- setup.cfg | 38 +- setup.py | 35 -- 17 files changed, 1338 insertions(+), 619 deletions(-) rename LICENSE.txt => LICENSE (97%) delete mode 100644 MANIFEST.in create mode 100644 ipdata/codes.py create mode 100644 ipdata/geofeeds.py create mode 100644 ipdata/lolcat.py delete mode 100644 ipdata/test_cli.py create mode 100644 ipdata/test_geofeeds.py delete mode 100644 setup.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index fda07a1..2229ba2 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,9 +17,9 @@ jobs: python -m pip install --upgrade pip python -m pip install python-dotenv if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi - - name: Test with unittest + - name: Test with pytest run: | - python -m unittest + python -m pytest env: IPDATA_API_KEY: ${{ secrets.IPDATA_API_KEY }} - name: Install build diff --git a/.gitignore b/.gitignore index 5310daa..f8a08f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ -publish.sh -.idea/ -v/ build/ -dist/ -/*.egg-info/ -/*.egg -*.pyc -ipdata/__pycache__ -.env \ No newline at end of file +__pycache__/ +*.csv +*.txt +!requirements.txt +*.jsonl +.env +*.egg-info/ +*.egg +*.pyc \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE similarity index 97% rename from LICENSE.txt rename to LICENSE index fca2978..79e94e7 100644 --- a/LICENSE.txt +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2017 ipdata LLC +Copyright 2022 ipdata LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index cccd231..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include pyproject.toml -include *.md -include LICENSE.txt -recursive-include ipdata * diff --git a/ipdata/__init__.py b/ipdata/__init__.py index f601895..ff8df3f 100644 --- a/ipdata/__init__.py +++ b/ipdata/__init__.py @@ -1,3 +1,34 @@ -from ipdata import * +""" + Convenience methods for using the library. -__version__ = "3.4.4" + Example: + >>> import ipdata + >>> ipdata.api_key = + >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") +""" +from .ipdata import IPData + +# Configuration +api_key = None +endpoint = None +default_client = None + + +def lookup(resource, fields=[]): + return _proxy("lookup", resource=resource, fields=fields) + + +def bulk(resources, fields=[]): + return _proxy("bulk", resources, fields=fields) + + +def _proxy(method, *args, **kwargs): + """Create an IPData client if one doesn't exist.""" + global default_client + if not default_client: + default_client = IPData( + api_key, + ) + + fn = getattr(default_client, method) + return fn(*args, **kwargs) diff --git a/ipdata/cli.py b/ipdata/cli.py index 6faa906..4365833 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -1,356 +1,481 @@ +"""This is the official IPData Command Line Interface. + +Use it to do one-off lookups or high throughput bulk lookups. With a ton of convenience features eg. +copying a result to the clipboard with '-c', pretty printing results in easy to parse panels with '-p' and more! + + $ ipdata --help + Usage: ipdata [OPTIONS] COMMAND [ARGS]... + + Welcome to the ipdata CLI + + Options: + --api-key TEXT Your ipdata API Key. Get one for free from + https://ipdata.co/sign-up.html + --help Show this message and exit. + + Commands: + lookup* + batch + init + usage + validate + + $ ipdata + Your IP + + $ ipdata 1.1.1.1 -f ip -f country_name + { + "ip": "1.1.1.1", + "asn": { + "asn": "AS13335", + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "route": "1.1.1.0/24", + "type": "business" + }, + "status": 200 + } + + $ ipdata 1.1.1.1 -f ip -f asn -c + 📋️ Copied result to clipboard! + + $ ipdata 8.8.8.8 -f ip -p + ╭───────────────╮ + │ country_name │ + │ United States │ + ╰───────────────╯ +""" import csv +import io import json -import multiprocessing +import logging import os import sys -from gzip import GzipFile -from ipaddress import ip_address -from itertools import chain, islice +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path -from sys import stderr, stdout -from rich import print_json +from sys import stderr import click -from setuptools._vendor.ordered_set import OrderedSet -from tqdm import tqdm - -try: - from ipdata import IPData -except: - from .ipdata import IPData - - -class WrongAPIKey(Exception): - pass - - -class IPAddressType(click.ParamType): - name = 'IP_Address' - - def convert(self, value, param, ctx): - try: - return ip_address(value) - except: - self.fail(f'{value} is not valid IPv4 or IPv6 address') - - def __str__(self) -> str: - return 'IP Address' - +import pyperclip +from click_default_group import DefaultGroup +from rich import print, print_json +from rich.columns import Columns +from rich.console import Console +from rich.logging import RichHandler +from rich.panel import Panel +from rich.progress import Progress +from rich.tree import Tree + +from lolcat import LolCat +from geofeeds import Geofeed, GeofeedValidationError +from ipdata import DotDict, IPData + +console = Console() + +FORMAT = "%(message)s" +logging.basicConfig( + level="ERROR", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] +) +log = logging.getLogger("rich") + +API_KEY_FILE = f"{Path.home()}/.ipdata" + + +def _lookup(ipdata, *args, **kwargs): + """ + Wrapper for looking up individual resources with error handling. Takes the same arguments and returns the same response as ipdata.lookup. + """ + try: + response = ipdata.lookup(*args, **kwargs) + except Exception as e: + log.error(f"Error during lookup: {e}") + else: + return response + + +def print_ascii_logo(): + """ + Print cool ascii logo with lolcat. + """ + options = DotDict({"animate": False, "os": 6, "spread": 3.0, "freq": 0.1}) + logo = """ + _ _ _ +(_)_ __ __| | __ _| |_ __ _ +| | '_ \ / _` |/ _` | __/ _` | +| | |_) | (_| | (_| | || (_| | +|_| .__/ \__,_|\__,_|\__\__,_| + |_| + """ + lol = LolCat() + lol.cat(io.StringIO(logo), options) + + +def pretty_print_data(data): + """ + Users rich to generate panels for individual API response fields for better readability! + + :param data: the response from ipdata.lookup + """ + # we print single value panels first then multiple value panels for better organization + single_value_panels = [] + multiple_value_panels = [] + + # if data is empty do nothing + if not data: + return -@click.group(help='CLI for ipdata API', invoke_without_command=True) -@click.option('--api-key', required=False, default=None, help='ipdata API Key') + # push the blocklists field up a level, it's usually nested under the threat data + if data.get("threat", {}).get("blocklists"): + data["blocklists"] = data.get("threat", {}).pop("blocklists") + + # generate panels! + for key, value in data.items(): + # simple case + if type(value) is str: + single_value_panels.append( + Panel(f"[b]{key}[/b]\n[yellow]{value}", expand=True) + ) + + # if the value is a dictionary we generate a tree inside a panel + if type(value) is dict: + tree = Tree(key) + for k, v in value.items(): + sub_tree = tree.add(f"[b]{k}[/b]\n[yellow]{v}") + multiple_value_panels.append(Panel(tree, expand=False)) + + # if value if a list we generate nested trees + if type(value) is list: + tree = Tree(key) + for item in value: + branch = tree.add("") + for k, v in item.items(): + _ = branch.add(f"[b]{k}[/b]\n[yellow]{v}") + multiple_value_panels.append(Panel(tree, expand=False)) + + # print the single value panels to the console + console.print(Columns(single_value_panels), overflow="ignore", crop=False) + + # print the multiple value panels to the console + console.print(Columns(multiple_value_panels), overflow="ignore", crop=False) + + +@click.group( + help="Welcome to the ipdata CLI", + cls=DefaultGroup, + default_if_no_args=True, + default="lookup", +) +@click.option( + "--api-key", + required=False, + default=None, + help="Your ipdata API Key. Get one for free from https://ipdata.co/sign-up.html", +) @click.pass_context def cli(ctx, api_key): - ctx.ensure_object(dict) - key = ctx.obj['api-key'] = get_and_check_api_key(api_key) - if not ctx.invoked_subcommand == "init": - if key is None: - print(f'Please initialize the cli by running "ipdata init " then try again', file=stderr) - sys.exit(1) - if ctx.invoked_subcommand is None: - print_ip_info(api_key) - else: - pass - + """ + This is the main entry point for the CLI. We first check for the presence of an API key at ~/.ipdata and make it accessible to all other commands in our group. + Note that the 'init' and 'validate' commands are the only ones that can run without an API key present. -def get_api_key_path(): - home = str(Path.home()) - return os.path.join(home, '.ipdata') - - -def get_api_key(): - key_path = get_api_key_path() - if os.path.exists(key_path): - with open(key_path, 'r') as f: - for line in f: - if line: - return line - else: - return None - - -def get_and_check_api_key(api_key: str = None) -> str: - if api_key is None: - api_key = get_api_key() - return api_key + :param api_key: A valid IPData API key + """ + ctx.ensure_object(dict) + key = ctx.obj["api-key"] = ( + Path(API_KEY_FILE).read_text() if Path(API_KEY_FILE).exists() else None + ) + + # Allow init and validate to run without an API key + if not ctx.invoked_subcommand in ["init", "validate"] and key is None: + print( + f'Please initialize the cli by running "ipdata init " then try again', + file=stderr, + ) + sys.exit(1) @cli.command() -@click.argument('api-key', required=True, type=str) +@click.argument( + "api-key", + required=True, + type=str, +) def init(api_key): - key_path = get_api_key_path() + """ + Initialize the CLI by setting an API key. + :param api_key: A valid IPData API key + """ ipdata = IPData(api_key) - res = ipdata.lookup('8.8.8.8') - if res['status'] == 200: - with open(key_path, 'w') as f: + + # We verify that the API key can successfully make requests by making one + with console.status("Verifying key ...", spinner="dots12"): + response = _lookup(ipdata, "8.8.8.8") + + # Handle success + if response.status == 200: + with open(API_KEY_FILE, "w") as f: f.write(api_key) - print(f'Successfully initialized.') - else: - print(f'Setup failed. (Error: {res["status"]}): {res["message"]}', - file=stderr) - - -def get_json_value(json, name): - if name in json: - return json[name] - elif name.find('.') != -1: - parts = name.split('.') - part = parts[0] if len(parts) > 1 else None - if part and part in json: - if isinstance(json[part], dict): - return get_json_value(json[part], '.'.join(parts[1:])) - elif isinstance(json[part], list): - return ','.join(json[part]) - elif json[part] is None: - return None - else: - raise ValueError(f'Unsupported type ({type(json[part])})') - else: - return None - - -def json_filter(json, fields): - if isinstance(json, dict): - res = dict() - for name in fields: - if name in json: - res[name] = json[name] - elif name.find('.') != -1: - parts = name.split('.') - part = parts[0] if len(parts) > 1 else None - if part and part in json: - sub_value = json_filter(json[part], ('.'.join(parts[1:]), )) - if isinstance(sub_value, dict): - if part not in res: - res[part] = sub_value - else: - res[part] = {**res[part], **sub_value} - else: - res[part] = sub_value - else: - pass - elif isinstance(json, list): - if len(fields) == 1: - res = [] - for el in json: - el_res = json_filter(el, fields) - for name in fields: - if name in el_res: - res.append(el_res[name]) - else: - raise ValueError('Cannot handle multiple fields in case of list object') - elif json is None: - res = None + print_ascii_logo() + print(f"✨ Successfully initialized.") else: - raise ValueError(f'Cannot handle value of type ({type(json)})') - return res + # Handle failure + print( + f"Initialization failed. Error: {response.status}): {response.message}", + file=stderr, + ) @cli.command() -@click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') +@click.option("--api-key", "-k", required=False, default=None, help="ipdata API Key") @click.pass_context -def me(ctx, fields): - print_ip_info(ctx.obj['api-key'], ip=None, fields=fields) - - -def do_lookup(ip_chunk, fields, api_key): - ip_data = IPData(get_and_check_api_key(api_key)) - - @filter_json_response(batch=True) - def get_bulk_result(ip_chunk, fields): - return ip_data.bulk_lookup(ip_chunk, fields=fields) +def usage(ctx, api_key): + """ + Get today's usage. Quota resets every day at 00:00 UTC. + """ + api_key = ctx.obj["api-key"] + ipdata = IPData(api_key) + with console.status("Getting usage ...", spinner="dots12"): + response = _lookup(ipdata) + print(f"{int(response.count):,}") + + +@cli.command(default=True) +@click.argument("resource", required=False, type=str, default="") +@click.option( + "--fields", "-f", required=False, multiple=True +) # TODO: add list of supported fields +@click.option("--api-key", "-k", required=False, default=None, help="ipdata API Key") +@click.option( + "--pretty-print", + "-p", + is_flag=True, + required=False, + default=False, + help="Pretty prints results as panels", +) +@click.option( + "--raw", + "-r", + is_flag=True, + required=False, + default=False, + help="Disable pretty printing", +) +@click.option( + "--copy", + "-c", + is_flag=True, + required=False, + default=False, + help="Copy the result to the clipboard", +) +@click.pass_context +def lookup(ctx, resource, fields, api_key, pretty_print, raw, copy): + """ + Lookup resources by using the IPData class methods. + + :param resource: The resource to lookup + :param fields: A list of supported fields passed as multiple parameters eg. "... -f ip -f country_name" + :param api_key: A valid API key + :param pretty_print: Whether to pretty print the response with panels + :param raw: Whether to print raw unformatted but syntax-highlighted JSON + :param copy: Copy the response to the clipboard + """ + api_key = api_key if api_key else ctx.obj["api-key"] + ipdata = IPData(api_key) - res = get_bulk_result(ip_chunk, fields=fields) - if res['status'] == 403: - raise WrongAPIKey(res['message']) - return res + with console.status( + f"""Looking up {resource if resource else "this device's IP address"}""", + spinner="dots12", + ): + data = _lookup(ipdata, resource, fields=fields) + + if copy: + pyperclip.copy(json.dumps(data)) + print(f"📋️ Copied result to clipboard!") + elif raw: + print(data) + elif pretty_print: + pretty_print_data(data) + else: + print_json(data=data) + + +def chunks(lst, n=100): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] + + +def process(resources, processor, fields): + """Farm out work to worker threads.""" + n_workers = os.cpu_count() + with ThreadPoolExecutor(n_workers) as executor: + futures = [ + executor.submit(processor, chunk, fields) for chunk in chunks(resources) + ] + for future in as_completed(futures): + try: + result = future.result() + except Exception as e: + log.error(e) + else: + yield result @cli.command() -@click.argument('ip-list', required=True, type=click.File(mode='r', encoding='utf-8')) -@click.option('--output', required=False, default=stdout, type=click.File(mode='wb', encoding='utf-8'), - help='Output to file or stdout') -@click.option('--output-format', required=False, type=click.Choice(('JSON', 'CSV'), case_sensitive=False), - default='JSON', help='Format of output') -@click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') -@click.option('--workers', '-w', 'workers', required=False, type=int, default=multiprocessing.cpu_count(), - help=f'Number of workers, max={multiprocessing.cpu_count()}') +@click.argument("input", required=True, type=click.File(mode="r", encoding="utf-8")) +@click.option( + "--fields", "-f", required=False, multiple=True +) # TODO: add list of supported fields +@click.option( + "--output", "-o", required=True, type=click.File(mode="w", encoding="utf-8") +) +@click.option( + "--format", + required=False, + type=click.Choice(("JSON", "CSV"), case_sensitive=False), + default="JSON", + help="Format of output", +) @click.pass_context -def batch(ctx, ip_list, output, output_format, fields, workers): - _batch(ip_list, output, output_format, fields, workers, ctx.obj['api-key']) - - -def _batch(ip_list, output, output_format, fields, workers, api_key): - if workers > multiprocessing.cpu_count(): - workers = multiprocessing.cpu_count() - elif workers <= 0: - workers = 1 - - extract_fields = fields.split(',') if fields else None - output_format = output_format.upper() - - if output_format == 'CSV' and extract_fields is None: - print(f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', file=stderr) +def batch(ctx, input, fields, output, format): + """ + Batch command for doing fast bulk processing. + + :param input: A list of resources to lookup either IP addresses or ASNs. You could mix them however this could break output processing when writing to CSV + :param fields: A list of valid fields to extract from the individual responses + :param output: The path to write the results to + :param format: The format to write the results to. When using the CSV format it is required to provide fields. + """ + + if format == "CSV" and not fields: + print( + f'You need to specify a "--fields" argument with a list of fields to extract to get results in CSV. To get entire responses use JSON.', + file=stderr, + ) return - def filter_ip(value): - value = value.strip() - try: - ip_address(value) - return True - except: - return False - - with tqdm(total=0) as t, \ - multiprocessing.Pool(workers) as pool: - try: - result_context = {} - if output_format == 'CSV': - print(f'# {fields}', file=output) # print comment with columns - result_context['writer'] = csv.writer(output) - - def print_result(res): - for result in res['responses']: - t.update() - result_context['writer'].writerow( - [get_json_value(result, k) for k in extract_fields] - ) - - elif output_format == 'JSON': - result_context['stream'] = GzipFile(fileobj=output) - - def print_result(res): - t.update(len(res['responses'])) - for res in res['responses']: - result_context['stream'].write(bytes(json.dumps(res), encoding='utf-8')) - result_context['stream'].write(b'\n') - - else: - print(f'Unsupported format: {output_format}', file=stderr) - sys.exit(1) - - def handle_error(e): - if isinstance(e, WrongAPIKey): - print('\n', e, file=stderr) - pool.terminate() - exit(1) - print(e, file=stderr) - - def chunks(iterable, size): - iterator = iter(iterable) - for first in iterator: - yield list(chain([first], islice(iterator, size - 1))) - - for chunk in chunks(ip_list, 100): - chunk = list(filter(filter_ip, map(lambda c: c.strip(), chunk))) - t.total += len(chunk) - pool.apply_async(do_lookup, - args=[chunk], - kwds=dict(fields=fields, api_key=api_key), - callback=print_result, - error_callback=handle_error) - - finally: - pool.close() - pool.join() - t.close() - - -@click.command() -@click.argument('ip', required=True, type=IPAddressType()) -@click.option('--fields', required=False, type=str, default=None, help='Comma separated list of fields to extract') -@click.option('--api-key', required=False, default=None, help='ipdata API Key') -def ip(ip, fields, api_key): - print_ip_info(get_and_check_api_key(api_key), ip=ip, fields=fields) - - -def print_ip_info(api_key, ip=None, fields=None): - try: - print_json(data=get_ip_info(api_key, ip, fields=fields)) - except ValueError as e: - print(f'Error: {e}', file=stderr) - - -def filter_json_response(batch=False): - def decorator(func): - def wrapper(*args, **kwargs): - if 'fields' in kwargs and kwargs['fields']: - fields = kwargs['fields'] - prepared_fields = OrderedSet(filter( - lambda x: len(x.strip()) > 0, - fields.split(',') if fields else None - )) - plain_fields = list(OrderedSet(map(lambda f: f.split('.')[0], prepared_fields))) - - del kwargs['fields'] - kwargs['fields'] = plain_fields - - if batch: - responses = func(*args, **kwargs) - filtered_responses = [] - if 'responses' in responses: - for r in responses['responses']: - filtered_responses.append(json_filter(r, prepared_fields)) - responses['responses'] = filtered_responses - return responses - else: - return json_filter(func(*args, **kwargs), prepared_fields) - else: - return func(*args, **kwargs) - return wrapper - return decorator + # Prepare requests + ipdata = IPData(ctx.obj["api-key"]) + resources = [resource.strip() for resource in input.readlines()] + bulk_results = process(resources, ipdata.bulk, fields) + + # Prepare CSV writing by expanding fieldnames eg. asn to asn, name, domain etc + csv_writer = None + fieldnames = [] + for field in fields: + if field == "asn": + fieldnames += [ + f"asn_{sub_field}" + for sub_field in ["asn", "name", "domain", "route", "type"] + ] + continue + if field == "company": + fieldnames += [ + f"company_{sub_field}" + for sub_field in ["asn", "name", "domain", "network", "type"] + ] + continue + if field == "threat": + fieldnames += [ + f"asn_{sub_field}" + for sub_field in [ + "is_tor", + "is_icloud_relay", + "is_proxy", + "is_datacenter", + "is_anonymous", + "is_known_attacker", + "is_known_abuser", + "is_threat", + "is_bogon", + "blocklists", + ] + ] + continue + if field in ipdata.valid_fields: + fieldnames += [field] + + # Do lookups concurrenctly using threads in batches of 100 each + with Progress() as progress: + # update progress bar + task = progress.add_task("[green]Processing...", total=len(resources)) + + # write individual results to file + for bulk_result in bulk_results: + for result in bulk_result.get("responses", {}): + progress.update(task, advance=1) + if format == "JSON": + output.write(f"{result}\n") + if format == "CSV": + if not csv_writer: + # create writer if none exists + csv_writer = csv.DictWriter(output, fieldnames=fieldnames) + csv_writer.writeheader() + + # prepare row + row = {} + for field, value in result.items(): + if type(value) is dict: + for k, v in result[field].items(): + row[f"{field}_{k}"] = v + else: + row[field] = value + # ensure no unexpected fields + try: + # handle when dict fields are none eg. when asn, company or carrier are empty + row_copy = row.copy() + for key in row_copy: + if key not in fieldnames: + row.pop(key) -@filter_json_response() -def get_ip_info(api_key, ip=None, fields=None): - api_key = get_and_check_api_key(api_key) - if api_key is None: - print(f'Please initialize the cli by running "ipdata init " then try again or pass an API key with the --api-key option', file=stderr) - sys.exit(1) - ip_data = IPData(api_key) - return ip_data.lookup(ip, fields=fields) + # write results + csv_writer.writerow(row) + except ValueError as e: + log.error(f"Error writing row: {row}. Error: {e}") @cli.command() -@click.pass_context -def info(ctx): - res = IPData(get_and_check_api_key(ctx.obj['api-key'])).lookup('8.8.8.8') - print(f'Number of requests made: {res["count"]}') +@click.argument("feed", required=True, type=str) +def validate(feed): + """ + Validates a geofeed file. + + :param feed: Either a valid URL (with the https:// path) or a file path + """ + geofeed = Geofeed(feed) + valid = True + for entry in geofeed.entries(): + if type(entry) is GeofeedValidationError: + log.error(entry) + valid = False + else: + try: + entry.validate() + # keep count of valid entries + geofeed.valid_count += 1 + except GeofeedValidationError as e: + valid = False + log.error(e) -@cli.command() -@click.option('--output', required=False, default=stdout, type=click.File(mode='w', encoding='utf-8'), - help='Output to file or stdout') -@click.option('--fields', required=True, type=str, default='ip,country_code', help='Comma separated list of fields to extract') -@click.option('--separator', help='The separator between the properties of the search results.', default=u'\t') -@click.argument('filenames', required=True, metavar='', type=click.Path(exists=True), nargs=-1) -def parse(output, fields, separator, filenames): - extract_fields = list(filter(lambda f: len(f) > 0, map(str.strip, fields.split(',')))) - for filename in filenames: - with GzipFile(filename) as gz: - for txt in gz: - js_data = json_filter(json.loads(txt), extract_fields) - for field in extract_fields: - value = get_json_value(js_data, field) - print(value, end=separator, file=output) - print(end='\n', file=output) - - -def is_ip_address(value): - try: - ip_address(value) - return True - except ValueError: - return False + if geofeed.total_count == 0: + log.error(f"The provided geofeed is empty") + valid_percentage = geofeed.valid_count / geofeed.total_count * 100 + print( + f"{geofeed.source} has {geofeed.valid_count:,} ({valid_percentage:.2f}%) valid entries." + ) -def todo(): - if len(sys.argv) >= 2 and is_ip_address(sys.argv[1]): - ip() - else: - cli(obj={}) + if not valid: + sys.exit(1) + + print( + "✨ Success! Your geofeed is valid and ready for publishing! Send an email to corrections@ipdata.co with the URL of this feed." + ) -if __name__ == '__main__': - todo() +if __name__ == "__main__": + cli() diff --git a/ipdata/codes.py b/ipdata/codes.py new file mode 100644 index 0000000..a83c5f0 --- /dev/null +++ b/ipdata/codes.py @@ -0,0 +1,2 @@ +COUNTRIES = ["AC", "AD", "AE", "AF", "AG", "AI", "AI", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BQ", "BR", "BS", "BT", "BU", "BV", "BW", "BX", "BY", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CP", "CQ", "CR", "CS", "CS", "CT", "CU", "CV", "CW", "CX", "CY", "CZ", "DD", "DE", "DG", "DJ", "DK", "DM", "DO", "DY", "DY", "DZ", "EA", "EC", "EE", "EF", "EG", "EH", "EM", "EP", "ER", "ES", "ET", "EU", "EV", "EW", "EZ", "FI", "FJ", "FK", "FL", "FM", "FO", "FQ", "FR", "FX", "GA", "GB", "GC", "GD", "GE", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "HV", "IB", "IC", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JA", "JE", "JM", "JO", "JP", "JT", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LF", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MI", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NH", "NI", "NL", "NO", "NP", "NQ", "NR", "NT", "NU", "NZ", "OA", "OM", "PA", "PC", "PE", "PF", "PG", "PH", "PI", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PU", "PW", "PY", "PZ", "QA", "RA", "RB", "RC", "RE", "RH", "RH", "RI", "RL", "RM", "RN", "RO", "RP", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SF", "SG", "SH", "SI", "SJ", "SK", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SU", "SV", "SX", "SY", "SZ", "TA", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TP", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UK", "UM", "UN", "US", "UY", "UZ", "VA", "VC", "VD", "VE", "VG", "VI", "VN", "VU", "WF", "WG", "WK", "WL", "WO", "WS", "WV", "YD", "YE", "YT", "YU", "YV", "ZA", "ZM", "ZR", "ZW"] +REGION_CODES = ["AD-02", "AD-03", "AD-04", "AD-05", "AD-06", "AD-07", "AD-08", "AE-AJ", "AE-AZ", "AE-DU", "AE-FU", "AE-RK", "AE-SH", "AE-UQ", "AF-BAL", "AF-BAM", "AF-BDG", "AF-BDS", "AF-BGL", "AF-DAY", "AF-FRA", "AF-FYB", "AF-GHA", "AF-GHO", "AF-HEL", "AF-HER", "AF-JOW", "AF-KAB", "AF-KAN", "AF-KAP", "AF-KDZ", "AF-KHO", "AF-KNR", "AF-LAG", "AF-LOG", "AF-NAN", "AF-NIM", "AF-NUR", "AF-PAN", "AF-PAR", "AF-PIA", "AF-PKA", "AF-SAM", "AF-SAR", "AF-TAK", "AF-URU", "AF-WAR", "AF-ZAB", "AG-10", "AG-11", "AG-03", "AG-04", "AG-05", "AG-06", "AG-07", "AG-08", "AL-01", "AL-02", "AL-03", "AL-04", "AL-05", "AL-06", "AL-07", "AL-08", "AL-09", "AL-10", "AL-11", "AL-12", "AM-AG", "AM-AR", "AM-AV", "AM-GR", "AM-KT", "AM-LO", "AM-SH", "AM-SU", "AM-TV", "AM-VD", "AM-ER", "AO-BGO", "AO-BGU", "AO-BIE", "AO-CAB", "AO-CCU", "AO-CNN", "AO-CNO", "AO-CUS", "AO-HUA", "AO-HUI", "AO-LNO", "AO-LSU", "AO-LUA", "AO-MAL", "AO-MOX", "AO-NAM", "AO-UIG", "AO-ZAI", "AR-A", "AR-B", "AR-D", "AR-E", "AR-F", "AR-G", "AR-H", "AR-J", "AR-K", "AR-L", "AR-M", "AR-N", "AR-P", "AR-Q", "AR-R", "AR-S", "AR-T", "AR-U", "AR-V", "AR-W", "AR-X", "AR-Y", "AR-Z", "AR-C", "AT-1", "AT-2", "AT-3", "AT-4", "AT-5", "AT-6", "AT-7", "AT-8", "AT-9", "AU-ACT", "AU-NT", "AU-NSW", "AU-QLD", "AU-SA", "AU-TAS", "AU-VIC", "AU-WA", "AZ-BA", "AZ-GA", "AZ-LA", "AZ-MI", "AZ-NA", "AZ-SA", "AZ-SM", "AZ-SR", "AZ-XA", "AZ-YE", "AZ-ABS", "AZ-AGA", "AZ-AGC", "AZ-AGM", "AZ-AGS", "AZ-AGU", "AZ-AST", "AZ-BAL", "AZ-BAR", "AZ-BEY", "AZ-BIL", "AZ-CAB", "AZ-CAL", "AZ-DAS", "AZ-FUZ", "AZ-GAD", "AZ-GOR", "AZ-GOY", "AZ-GYG", "AZ-HAC", "AZ-IMI", "AZ-ISM", "AZ-KAL", "AZ-KUR", "AZ-LAC", "AZ-LAN", "AZ-LER", "AZ-MAS", "AZ-NEF", "AZ-OGU", "AZ-QAB", "AZ-QAX", "AZ-QAZ", "AZ-QBA", "AZ-QBI", "AZ-QOB", "AZ-QUS", "AZ-SAB", "AZ-SAK", "AZ-SAL", "AZ-SAT", "AZ-SBN", "AZ-SIY", "AZ-SKR", "AZ-SMI", "AZ-SMX", "AZ-SUS", "AZ-TAR", "AZ-TOV", "AZ-UCA", "AZ-XAC", "AZ-XCI", "AZ-XIZ", "AZ-XVD", "AZ-YAR", "AZ-YEV", "AZ-ZAN", "AZ-ZAQ", "AZ-ZAR", "AZ-NX", "AZ-NV", "AZ-BAB", "AZ-CUL", "AZ-KAN", "AZ-ORD", "AZ-SAD", "AZ-SAH", "AZ-SAR", "BA-BIH", "BA-SRP", "BA-BRC", "BB-01", "BB-02", "BB-03", "BB-04", "BB-05", "BB-06", "BB-07", "BB-08", "BB-09", "BB-10", "BB-11", "BD-A", "BD-02", "BD-06", "BD-07", "BD-25", "BD-50", "BD-51", "BD-B", "BD-01", "BD-04", "BD-08", "BD-09", "BD-10", "BD-11", "BD-16", "BD-29", "BD-31", "BD-47", "BD-56", "BD-C", "BD-13", "BD-15", "BD-17", "BD-18", "BD-26", "BD-33", "BD-35", "BD-36", "BD-40", "BD-42", "BD-53", "BD-62", "BD-63", "BD-D", "BD-05", "BD-12", "BD-22", "BD-23", "BD-27", "BD-30", "BD-37", "BD-39", "BD-43", "BD-58", "BD-E", "BD-03", "BD-24", "BD-44", "BD-45", "BD-48", "BD-49", "BD-54", "BD-59", "BD-F", "BD-14", "BD-19", "BD-28", "BD-32", "BD-46", "BD-52", "BD-55", "BD-64", "BD-G", "BD-20", "BD-38", "BD-60", "BD-61", "BD-H", "BD-21", "BD-34", "BD-41", "BD-57", "BE-BRU", "BE-VLG", "BE-VAN", "BE-VBR", "BE-VLI", "BE-VOV", "BE-VWV", "BE-WAL", "BE-WBR", "BE-WHT", "BE-WLG", "BE-WLX", "BE-WNA", "BF-01", "BF-BAL", "BF-BAN", "BF-KOS", "BF-MOU", "BF-NAY", "BF-SOR", "BF-02", "BF-COM", "BF-LER", "BF-03", "BF-KAD", "BF-04", "BF-BLG", "BF-KOP", "BF-KOT", "BF-05", "BF-BAM", "BF-NAM", "BF-SMT", "BF-06", "BF-BLK", "BF-SIS", "BF-SNG", "BF-ZIR", "BF-07", "BF-BAZ", "BF-NAO", "BF-ZOU", "BF-08", "BF-GNA", "BF-GOU", "BF-KMD", "BF-KMP", "BF-TAP", "BF-09", "BF-HOU", "BF-KEN", "BF-TUI", "BF-10", "BF-LOR", "BF-PAS", "BF-YAT", "BF-ZON", "BF-11", "BF-GAN", "BF-KOW", "BF-OUB", "BF-12", "BF-OUD", "BF-SEN", "BF-SOM", "BF-YAG", "BF-13", "BF-BGR", "BF-IOB", "BF-NOU", "BF-PON", "BG-01", "BG-02", "BG-03", "BG-04", "BG-05", "BG-06", "BG-07", "BG-08", "BG-09", "BG-10", "BG-11", "BG-12", "BG-13", "BG-14", "BG-15", "BG-16", "BG-17", "BG-18", "BG-19", "BG-20", "BG-21", "BG-22", "BG-23", "BG-24", "BG-25", "BG-26", "BG-27", "BG-28", "BH-13", "BH-14", "BH-15", "BH-17", "BI-BB", "BI-BL", "BI-BM", "BI-BR", "BI-CA", "BI-CI", "BI-GI", "BI-KI", "BI-KR", "BI-KY", "BI-MA", "BI-MU", "BI-MW", "BI-MY", "BI-NG", "BI-RM", "BI-RT", "BI-RY", "BJ-AK", "BJ-AL", "BJ-AQ", "BJ-BO", "BJ-CO", "BJ-DO", "BJ-KO", "BJ-LI", "BJ-MO", "BJ-OU", "BJ-PL", "BJ-ZO", "BN-BE", "BN-BM", "BN-TE", "BN-TU", "BO-B", "BO-C", "BO-H", "BO-L", "BO-N", "BO-O", "BO-P", "BO-S", "BO-T", "BQ-BO", "BQ-SA", "BQ-SE", "BR-AC", "BR-AL", "BR-AM", "BR-AP", "BR-BA", "BR-CE", "BR-ES", "BR-GO", "BR-MA", "BR-MG", "BR-MS", "BR-MT", "BR-PA", "BR-PB", "BR-PE", "BR-PI", "BR-PR", "BR-RJ", "BR-RN", "BR-RO", "BR-RR", "BR-RS", "BR-SC", "BR-SE", "BR-SP", "BR-TO", "BR-DF", "BS-AK", "BS-BI", "BS-BP", "BS-BY", "BS-CE", "BS-CI", "BS-CK", "BS-CO", "BS-CS", "BS-EG", "BS-EX", "BS-FP", "BS-GC", "BS-HI", "BS-HT", "BS-IN", "BS-LI", "BS-MC", "BS-MG", "BS-MI", "BS-NE", "BS-NO", "BS-NS", "BS-RC", "BS-RI", "BS-SA", "BS-SE", "BS-SO", "BS-SS", "BS-SW", "BS-WG", "BS-NP", "BT-11", "BT-12", "BT-13", "BT-14", "BT-15", "BT-21", "BT-22", "BT-23", "BT-24", "BT-31", "BT-32", "BT-33", "BT-34", "BT-41", "BT-42", "BT-43", "BT-44", "BT-45", "BT-GA", "BT-TY", "BW-CE", "BW-CH", "BW-GH", "BW-KG", "BW-KL", "BW-KW", "BW-NE", "BW-NW", "BW-SE", "BW-SO", "BW-JW", "BW-LO", "BW-SP", "BW-ST", "BW-FR", "BW-GA", "BY-HM", "BY-BR", "BY-HO", "BY-HR", "BY-MA", "BY-MI", "BY-VI", "BZ-BZ", "BZ-CY", "BZ-CZL", "BZ-OW", "BZ-SC", "BZ-TOL", "CA-NT", "CA-NU", "CA-YT", "CA-AB", "CA-BC", "CA-MB", "CA-NB", "CA-NL", "CA-NS", "CA-ON", "CA-PE", "CA-QC", "CA-SK", "CD-BC", "CD-BU", "CD-EQ", "CD-HK", "CD-HL", "CD-HU", "CD-IT", "CD-KC", "CD-KE", "CD-KG", "CD-KL", "CD-KS", "CD-LO", "CD-LU", "CD-MA", "CD-MN", "CD-MO", "CD-NK", "CD-NU", "CD-SA", "CD-SK", "CD-SU", "CD-TA", "CD-TO", "CD-TU", "CD-KN", "CF-KB", "CF-SE", "CF-AC", "CF-BB", "CF-BK", "CF-HK", "CF-HM", "CF-HS", "CF-KG", "CF-LB", "CF-MB", "CF-MP", "CF-NM", "CF-OP", "CF-UK", "CF-VK", "CF-BGF", "CG-11", "CG-12", "CG-13", "CG-14", "CG-15", "CG-16", "CG-2", "CG-5", "CG-7", "CG-8", "CG-9", "CG-BZV", "CH-AG", "CH-AI", "CH-AR", "CH-BE", "CH-BL", "CH-BS", "CH-FR", "CH-GE", "CH-GL", "CH-GR", "CH-JU", "CH-LU", "CH-NE", "CH-NW", "CH-OW", "CH-SG", "CH-SH", "CH-SO", "CH-SZ", "CH-TG", "CH-TI", "CH-UR", "CH-VD", "CH-VS", "CH-ZG", "CH-ZH", "CI-AB", "CI-YM", "CI-BS", "CI-CM", "CI-DN", "CI-GD", "CI-LC", "CI-LG", "CI-MG", "CI-SM", "CI-SV", "CI-VB", "CI-WR", "CI-ZZ", "CL-AI", "CL-AN", "CL-AP", "CL-AR", "CL-AT", "CL-BI", "CL-CO", "CL-LI", "CL-LL", "CL-LR", "CL-MA", "CL-ML", "CL-NB", "CL-RM", "CL-TA", "CL-VS", "CM-AD", "CM-CE", "CM-EN", "CM-ES", "CM-LT", "CM-NO", "CM-NW", "CM-OU", "CM-SU", "CM-SW", "CN-AH", "CN-FJ", "CN-GD", "CN-GS", "CN-GZ", "CN-HA", "CN-HB", "CN-HE", "CN-HI", "CN-HL", "CN-HN", "CN-JL", "CN-JS", "CN-JX", "CN-LN", "CN-QH", "CN-SC", "CN-SD", "CN-SN", "CN-SX", "CN-TW", "CN-YN", "CN-ZJ", "CN-GX", "CN-NM", "CN-NX", "CN-XJ", "CN-XZ", "CN-HK", "CN-MO", "CN-BJ", "CN-CQ", "CN-SH", "CN-TJ", "CO-AMA", "CO-ANT", "CO-ARA", "CO-ATL", "CO-BOL", "CO-BOY", "CO-CAL", "CO-CAQ", "CO-CAS", "CO-CAU", "CO-CES", "CO-CHO", "CO-COR", "CO-CUN", "CO-GUA", "CO-GUV", "CO-HUI", "CO-LAG", "CO-MAG", "CO-MET", "CO-NAR", "CO-NSA", "CO-PUT", "CO-QUI", "CO-RIS", "CO-SAN", "CO-SAP", "CO-SUC", "CO-TOL", "CO-VAC", "CO-VAU", "CO-VID", "CO-DC", "CR-A", "CR-C", "CR-G", "CR-H", "CR-L", "CR-P", "CR-SJ", "CU-01", "CU-03", "CU-04", "CU-05", "CU-06", "CU-07", "CU-08", "CU-09", "CU-10", "CU-11", "CU-12", "CU-13", "CU-14", "CU-15", "CU-16", "CU-99", "CV-B", "CV-BV", "CV-PA", "CV-PN", "CV-RB", "CV-RG", "CV-SL", "CV-SV", "CV-TS", "CV-S", "CV-BR", "CV-CA", "CV-CF", "CV-CR", "CV-MA", "CV-MO", "CV-PR", "CV-RS", "CV-SD", "CV-SF", "CV-SM", "CV-SO", "CV-SS", "CV-TA", "CY-01", "CY-02", "CY-03", "CY-04", "CY-05", "CY-06", "CZ-20", "CZ-201", "CZ-202", "CZ-203", "CZ-204", "CZ-205", "CZ-206", "CZ-207", "CZ-208", "CZ-209", "CZ-20A", "CZ-20B", "CZ-20C", "CZ-31", "CZ-311", "CZ-312", "CZ-313", "CZ-314", "CZ-315", "CZ-316", "CZ-317", "CZ-32", "CZ-321", "CZ-322", "CZ-323", "CZ-324", "CZ-325", "CZ-326", "CZ-327", "CZ-41", "CZ-411", "CZ-412", "CZ-413", "CZ-42", "CZ-421", "CZ-422", "CZ-423", "CZ-424", "CZ-425", "CZ-426", "CZ-427", "CZ-51", "CZ-511", "CZ-512", "CZ-513", "CZ-514", "CZ-52", "CZ-521", "CZ-522", "CZ-523", "CZ-524", "CZ-525", "CZ-53", "CZ-531", "CZ-532", "CZ-533", "CZ-534", "CZ-63", "CZ-631", "CZ-632", "CZ-633", "CZ-634", "CZ-635", "CZ-64", "CZ-641", "CZ-642", "CZ-643", "CZ-644", "CZ-645", "CZ-646", "CZ-647", "CZ-71", "CZ-711", "CZ-712", "CZ-713", "CZ-714", "CZ-715", "CZ-72", "CZ-721", "CZ-722", "CZ-723", "CZ-724", "CZ-80", "CZ-801", "CZ-802", "CZ-803", "CZ-804", "CZ-805", "CZ-806", "CZ-10", "DE-BB", "DE-BE", "DE-BW", "DE-BY", "DE-HB", "DE-HE", "DE-HH", "DE-MV", "DE-NI", "DE-NW", "DE-RP", "DE-SH", "DE-SL", "DE-SN", "DE-ST", "DE-TH", "DJ-AR", "DJ-AS", "DJ-DI", "DJ-OB", "DJ-TA", "DJ-DJ", "DK-81", "DK-82", "DK-83", "DK-84", "DK-85", "DM-02", "DM-03", "DM-04", "DM-05", "DM-06", "DM-07", "DM-08", "DM-09", "DM-10", "DM-11", "DO-33", "DO-06", "DO-14", "DO-19", "DO-20", "DO-34", "DO-05", "DO-15", "DO-26", "DO-27", "DO-35", "DO-09", "DO-18", "DO-25", "DO-36", "DO-13", "DO-24", "DO-28", "DO-37", "DO-07", "DO-22", "DO-38", "DO-03", "DO-04", "DO-10", "DO-16", "DO-39", "DO-23", "DO-29", "DO-30", "DO-40", "DO-32", "DO-01", "DO-41", "DO-02", "DO-17", "DO-21", "DO-31", "DO-42", "DO-08", "DO-11", "DO-12", "DZ-01", "DZ-02", "DZ-03", "DZ-04", "DZ-05", "DZ-06", "DZ-07", "DZ-08", "DZ-09", "DZ-10", "DZ-11", "DZ-12", "DZ-13", "DZ-14", "DZ-15", "DZ-16", "DZ-17", "DZ-18", "DZ-19", "DZ-20", "DZ-21", "DZ-22", "DZ-23", "DZ-24", "DZ-25", "DZ-26", "DZ-27", "DZ-28", "DZ-29", "DZ-30", "DZ-31", "DZ-32", "DZ-33", "DZ-34", "DZ-35", "DZ-36", "DZ-37", "DZ-38", "DZ-39", "DZ-40", "DZ-41", "DZ-42", "DZ-43", "DZ-44", "DZ-45", "DZ-46", "DZ-47", "DZ-48", "EC-A", "EC-B", "EC-C", "EC-D", "EC-E", "EC-F", "EC-G", "EC-H", "EC-I", "EC-L", "EC-M", "EC-N", "EC-O", "EC-P", "EC-R", "EC-S", "EC-SD", "EC-SE", "EC-T", "EC-U", "EC-W", "EC-X", "EC-Y", "EC-Z", "EE-37", "EE-296", "EE-424", "EE-446", "EE-784", "EE-141", "EE-198", "EE-245", "EE-305", "EE-338", "EE-353", "EE-431", "EE-651", "EE-653", "EE-719", "EE-726", "EE-890", "EE-39", "EE-205", "EE-45", "EE-321", "EE-511", "EE-514", "EE-735", "EE-130", "EE-251", "EE-442", "EE-803", "EE-50", "EE-247", "EE-486", "EE-618", "EE-52", "EE-567", "EE-255", "EE-834", "EE-56", "EE-184", "EE-441", "EE-907", "EE-60", "EE-663", "EE-191", "EE-272", "EE-661", "EE-792", "EE-901", "EE-903", "EE-928", "EE-64", "EE-284", "EE-622", "EE-708", "EE-68", "EE-624", "EE-214", "EE-303", "EE-430", "EE-638", "EE-712", "EE-809", "EE-71", "EE-293", "EE-317", "EE-503", "EE-668", "EE-74", "EE-478", "EE-689", "EE-714", "EE-79", "EE-793", "EE-171", "EE-283", "EE-291", "EE-432", "EE-528", "EE-586", "EE-796", "EE-81", "EE-557", "EE-824", "EE-855", "EE-84", "EE-897", "EE-480", "EE-615", "EE-899", "EE-87", "EE-919", "EE-142", "EE-698", "EE-732", "EE-917", "EG-ALX", "EG-ASN", "EG-AST", "EG-BA", "EG-BH", "EG-BNS", "EG-C", "EG-DK", "EG-DT", "EG-FYM", "EG-GH", "EG-GZ", "EG-IS", "EG-JS", "EG-KB", "EG-KFS", "EG-KN", "EG-LX", "EG-MN", "EG-MNF", "EG-MT", "EG-PTS", "EG-SHG", "EG-SHR", "EG-SIN", "EG-SUZ", "EG-WAD", "ER-AN", "ER-DK", "ER-DU", "ER-GB", "ER-MA", "ER-SK", "ES-AN", "ES-AL", "ES-CA", "ES-CO", "ES-GR", "ES-H", "ES-J", "ES-MA", "ES-SE", "ES-AR", "ES-HU", "ES-TE", "ES-Z", "ES-AS", "ES-O", "ES-CB", "ES-S", "ES-CL", "ES-AV", "ES-BU", "ES-LE", "ES-P", "ES-SA", "ES-SG", "ES-SO", "ES-VA", "ES-ZA", "ES-CM", "ES-AB", "ES-CR", "ES-CU", "ES-GU", "ES-TO", "ES-CN", "ES-GC", "ES-TF", "ES-CT", "ES-B", "ES-GI", "ES-L", "ES-T", "ES-EX", "ES-BA", "ES-CC", "ES-GA", "ES-C", "ES-LU", "ES-OR", "ES-PO", "ES-IB", "ES-PM", "ES-MC", "ES-MU", "ES-MD", "ES-M", "ES-NC", "ES-NA", "ES-PV", "ES-BI", "ES-SS", "ES-VI", "ES-RI", "ES-LO", "ES-VC", "ES-A", "ES-CS", "ES-V", "ES-CE", "ES-ML", "ET-AF", "ET-AM", "ET-BE", "ET-GA", "ET-HA", "ET-OR", "ET-SI", "ET-SN", "ET-SO", "ET-TI", "ET-AA", "ET-DD", "FI-01", "FI-02", "FI-03", "FI-04", "FI-05", "FI-06", "FI-07", "FI-08", "FI-09", "FI-10", "FI-11", "FI-12", "FI-13", "FI-14", "FI-15", "FI-16", "FI-17", "FI-18", "FI-19", "FJ-R", "FJ-C", "FJ-09", "FJ-10", "FJ-12", "FJ-13", "FJ-14", "FJ-E", "FJ-04", "FJ-05", "FJ-06", "FJ-N", "FJ-02", "FJ-03", "FJ-07", "FJ-W", "FJ-01", "FJ-08", "FJ-11", "FM-KSA", "FM-PNI", "FM-TRK", "FM-YAP", "FR-ARA", "FR-01", "FR-03", "FR-07", "FR-15", "FR-26", "FR-38", "FR-42", "FR-43", "FR-63", "FR-69", "FR-73", "FR-74", "FR-69M", "FR-BFC", "FR-21", "FR-25", "FR-39", "FR-58", "FR-70", "FR-71", "FR-89", "FR-90", "FR-BRE", "FR-22", "FR-29", "FR-35", "FR-56", "FR-CVL", "FR-18", "FR-28", "FR-36", "FR-37", "FR-41", "FR-45", "FR-GES", "FR-08", "FR-10", "FR-51", "FR-52", "FR-54", "FR-55", "FR-57", "FR-88", "FR-6AE", "FR-67", "FR-68", "FR-HDF", "FR-02", "FR-59", "FR-60", "FR-62", "FR-80", "FR-IDF", "FR-77", "FR-78", "FR-91", "FR-92", "FR-93", "FR-94", "FR-95", "FR-75C", "FR-NAQ", "FR-16", "FR-17", "FR-19", "FR-23", "FR-24", "FR-33", "FR-40", "FR-47", "FR-64", "FR-79", "FR-86", "FR-87", "FR-NOR", "FR-14", "FR-27", "FR-50", "FR-61", "FR-76", "FR-OCC", "FR-09", "FR-11", "FR-12", "FR-30", "FR-31", "FR-32", "FR-34", "FR-46", "FR-48", "FR-65", "FR-66", "FR-81", "FR-82", "FR-PAC", "FR-04", "FR-05", "FR-06", "FR-13", "FR-83", "FR-84", "FR-PDL", "FR-44", "FR-49", "FR-53", "FR-72", "FR-85", "FR-CP", "FR-BL", "FR-MF", "FR-PF", "FR-PM", "FR-WF", "FR-20R", "FR-2A", "FR-2B", "FR-NC", "FR-TF", "FR-972", "FR-973", "FR-971", "FR-974", "FR-976", "GA-1", "GA-2", "GA-3", "GA-4", "GA-5", "GA-6", "GA-7", "GA-8", "GA-9", "GB-ENG", "GB-BKM", "GB-CAM", "GB-CMA", "GB-DBY", "GB-DEV", "GB-DOR", "GB-ESS", "GB-ESX", "GB-GLS", "GB-HAM", "GB-HRT", "GB-KEN", "GB-LAN", "GB-LEC", "GB-LIN", "GB-NFK", "GB-NTH", "GB-NTT", "GB-NYK", "GB-OXF", "GB-SFK", "GB-SOM", "GB-SRY", "GB-STS", "GB-WAR", "GB-WOR", "GB-WSX", "GB-BAS", "GB-BBD", "GB-BCP", "GB-BDF", "GB-BNH", "GB-BPL", "GB-BRC", "GB-BST", "GB-CBF", "GB-CHE", "GB-CHW", "GB-CON", "GB-DAL", "GB-DER", "GB-DUR", "GB-ERY", "GB-HAL", "GB-HEF", "GB-HPL", "GB-IOS", "GB-IOW", "GB-KHL", "GB-LCE", "GB-LUT", "GB-MDB", "GB-MDW", "GB-MIK", "GB-NBL", "GB-NEL", "GB-NGM", "GB-NLN", "GB-NSM", "GB-PLY", "GB-POR", "GB-PTE", "GB-RCC", "GB-RDG", "GB-RUT", "GB-SGC", "GB-SHR", "GB-SLG", "GB-SOS", "GB-STE", "GB-STH", "GB-STT", "GB-SWD", "GB-TFW", "GB-THR", "GB-TOB", "GB-WBK", "GB-WIL", "GB-WNM", "GB-WOK", "GB-WRT", "GB-YOR", "GB-BIR", "GB-BNS", "GB-BOL", "GB-BRD", "GB-BUR", "GB-CLD", "GB-COV", "GB-DNC", "GB-DUD", "GB-GAT", "GB-KIR", "GB-KWL", "GB-LDS", "GB-LIV", "GB-MAN", "GB-NET", "GB-NTY", "GB-OLD", "GB-RCH", "GB-ROT", "GB-SAW", "GB-SFT", "GB-SHF", "GB-SHN", "GB-SKP", "GB-SLF", "GB-SND", "GB-SOL", "GB-STY", "GB-TAM", "GB-TRF", "GB-WGN", "GB-WKF", "GB-WLL", "GB-WLV", "GB-WRL", "GB-BDG", "GB-BEN", "GB-BEX", "GB-BNE", "GB-BRY", "GB-CMD", "GB-CRY", "GB-EAL", "GB-ENF", "GB-GRE", "GB-HAV", "GB-HCK", "GB-HIL", "GB-HMF", "GB-HNS", "GB-HRW", "GB-HRY", "GB-ISL", "GB-KEC", "GB-KTT", "GB-LBH", "GB-LEW", "GB-MRT", "GB-NWM", "GB-RDB", "GB-RIC", "GB-STN", "GB-SWK", "GB-TWH", "GB-WFT", "GB-WND", "GB-WSM", "GB-LND", "GB-SCT", "GB-ABD", "GB-ABE", "GB-AGB", "GB-ANS", "GB-CLK", "GB-DGY", "GB-DND", "GB-EAY", "GB-EDH", "GB-EDU", "GB-ELN", "GB-ELS", "GB-ERW", "GB-FAL", "GB-FIF", "GB-GLG", "GB-HLD", "GB-IVC", "GB-MLN", "GB-MRY", "GB-NAY", "GB-NLK", "GB-ORK", "GB-PKN", "GB-RFW", "GB-SAY", "GB-SCB", "GB-SLK", "GB-STG", "GB-WDU", "GB-WLN", "GB-ZET", "GB-WLS", "GB-AGY", "GB-BGE", "GB-BGW", "GB-CAY", "GB-CGN", "GB-CMN", "GB-CRF", "GB-CWY", "GB-DEN", "GB-FLN", "GB-GWN", "GB-MON", "GB-MTY", "GB-NTL", "GB-NWP", "GB-PEM", "GB-POW", "GB-RCT", "GB-SWA", "GB-TOF", "GB-VGL", "GB-WRX", "GB-NIR", "GB-ABC", "GB-AND", "GB-ANN", "GB-BFS", "GB-CCG", "GB-DRS", "GB-FMO", "GB-LBC", "GB-MEA", "GB-MUL", "GB-NMD", "GD-01", "GD-02", "GD-03", "GD-04", "GD-05", "GD-06", "GD-10", "GE-GU", "GE-IM", "GE-KA", "GE-KK", "GE-MM", "GE-RL", "GE-SJ", "GE-SK", "GE-SZ", "GE-AB", "GE-AJ", "GE-TB", "GH-AA", "GH-AF", "GH-AH", "GH-BE", "GH-BO", "GH-CP", "GH-EP", "GH-NE", "GH-NP", "GH-OT", "GH-SV", "GH-TV", "GH-UE", "GH-UW", "GH-WN", "GH-WP", "GL-AV", "GL-KU", "GL-QE", "GL-QT", "GL-SM", "GM-L", "GM-M", "GM-N", "GM-U", "GM-W", "GM-B", "GN-C", "GN-B", "GN-BF", "GN-BK", "GN-FR", "GN-GA", "GN-KN", "GN-D", "GN-CO", "GN-DU", "GN-FO", "GN-KD", "GN-TE", "GN-F", "GN-DB", "GN-DI", "GN-FA", "GN-KS", "GN-K", "GN-KA", "GN-KE", "GN-KO", "GN-MD", "GN-SI", "GN-L", "GN-KB", "GN-LA", "GN-LE", "GN-ML", "GN-TO", "GN-M", "GN-DL", "GN-MM", "GN-PI", "GN-N", "GN-BE", "GN-GU", "GN-LO", "GN-MC", "GN-NZ", "GN-YO", "GQ-C", "GQ-CS", "GQ-DJ", "GQ-KN", "GQ-LI", "GQ-WN", "GQ-I", "GQ-AN", "GQ-BN", "GQ-BS", "GR-69", "GR-A", "GR-B", "GR-C", "GR-D", "GR-E", "GR-F", "GR-G", "GR-H", "GR-I", "GR-J", "GR-K", "GR-L", "GR-M", "GT-01", "GT-02", "GT-03", "GT-04", "GT-05", "GT-06", "GT-07", "GT-08", "GT-09", "GT-10", "GT-11", "GT-12", "GT-13", "GT-14", "GT-15", "GT-16", "GT-17", "GT-18", "GT-19", "GT-20", "GT-21", "GT-22", "GW-BS", "GW-L", "GW-BA", "GW-GA", "GW-N", "GW-BM", "GW-CA", "GW-OI", "GW-S", "GW-BL", "GW-QU", "GW-TO", "GY-BA", "GY-CU", "GY-DE", "GY-EB", "GY-ES", "GY-MA", "GY-PM", "GY-PT", "GY-UD", "GY-UT", "HN-AT", "HN-CH", "HN-CL", "HN-CM", "HN-CP", "HN-CR", "HN-EP", "HN-FM", "HN-GD", "HN-IB", "HN-IN", "HN-LE", "HN-LP", "HN-OC", "HN-OL", "HN-SB", "HN-VA", "HN-YO", "HR-01", "HR-02", "HR-03", "HR-04", "HR-05", "HR-06", "HR-07", "HR-08", "HR-09", "HR-10", "HR-11", "HR-12", "HR-13", "HR-14", "HR-15", "HR-16", "HR-17", "HR-18", "HR-19", "HR-20", "HR-21", "HT-AR", "HT-CE", "HT-GA", "HT-ND", "HT-NE", "HT-NI", "HT-NO", "HT-OU", "HT-SD", "HT-SE", "HU-BA", "HU-BE", "HU-BK", "HU-BZ", "HU-CS", "HU-FE", "HU-GS", "HU-HB", "HU-HE", "HU-JN", "HU-KE", "HU-NO", "HU-PE", "HU-SO", "HU-SZ", "HU-TO", "HU-VA", "HU-VE", "HU-ZA", "HU-BC", "HU-DE", "HU-DU", "HU-EG", "HU-ER", "HU-GY", "HU-HV", "HU-KM", "HU-KV", "HU-MI", "HU-NK", "HU-NY", "HU-PS", "HU-SD", "HU-SF", "HU-SH", "HU-SK", "HU-SN", "HU-SS", "HU-ST", "HU-TB", "HU-VM", "HU-ZE", "HU-BU", "ID-JW", "ID-BT", "ID-JB", "ID-JI", "ID-JT", "ID-YO", "ID-JK", "ID-KA", "ID-KB", "ID-KI", "ID-KS", "ID-KT", "ID-KU", "ID-ML", "ID-MA", "ID-MU", "ID-NU", "ID-BA", "ID-NB", "ID-NT", "ID-PP", "ID-PA", "ID-PB", "ID-SL", "ID-GO", "ID-SA", "ID-SG", "ID-SN", "ID-SR", "ID-ST", "ID-SM", "ID-AC", "ID-BB", "ID-BE", "ID-JA", "ID-KR", "ID-LA", "ID-RI", "ID-SB", "ID-SS", "ID-SU", "IE-C", "IE-G", "IE-LM", "IE-MO", "IE-RN", "IE-SO", "IE-L", "IE-CW", "IE-D", "IE-KE", "IE-KK", "IE-LD", "IE-LH", "IE-LS", "IE-MH", "IE-OY", "IE-WH", "IE-WW", "IE-WX", "IE-M", "IE-CE", "IE-CO", "IE-KY", "IE-LK", "IE-TA", "IE-WD", "IE-U", "IE-CN", "IE-DL", "IE-MN", "IL-D", "IL-HA", "IL-JM", "IL-M", "IL-TA", "IL-Z", "IN-AN", "IN-CH", "IN-DH", "IN-DL", "IN-JK", "IN-LA", "IN-LD", "IN-PY", "IN-AP", "IN-AR", "IN-AS", "IN-BR", "IN-CT", "IN-GA", "IN-GJ", "IN-HP", "IN-HR", "IN-JH", "IN-KA", "IN-KL", "IN-MH", "IN-ML", "IN-MN", "IN-MP", "IN-MZ", "IN-NL", "IN-OR", "IN-PB", "IN-RJ", "IN-SK", "IN-TG", "IN-TN", "IN-TR", "IN-UP", "IN-UT", "IN-WB", "IQ-AN", "IQ-BA", "IQ-BB", "IQ-BG", "IQ-DI", "IQ-DQ", "IQ-KA", "IQ-KI", "IQ-MA", "IQ-MU", "IQ-NA", "IQ-NI", "IQ-QA", "IQ-SD", "IQ-WA", "IQ-KR", "IQ-AR", "IQ-DA", "IQ-SU", "IR-00", "IR-01", "IR-02", "IR-03", "IR-04", "IR-05", "IR-06", "IR-07", "IR-08", "IR-09", "IR-10", "IR-11", "IR-12", "IR-13", "IR-14", "IR-15", "IR-16", "IR-17", "IR-18", "IR-19", "IR-20", "IR-21", "IR-22", "IR-23", "IR-24", "IR-25", "IR-26", "IR-27", "IR-28", "IR-29", "IR-30", "IS-1", "IS-GAR", "IS-HAF", "IS-KJO", "IS-KOP", "IS-MOS", "IS-RKV", "IS-SEL", "IS-2", "IS-GRN", "IS-RKN", "IS-SDN", "IS-SVG", "IS-3", "IS-AKN", "IS-BOG", "IS-DAB", "IS-EOM", "IS-GRU", "IS-HEL", "IS-HVA", "IS-SKO", "IS-SNF", "IS-STY", "IS-4", "IS-ARN", "IS-BOL", "IS-ISA", "IS-KAL", "IS-RHH", "IS-SDV", "IS-STR", "IS-TAL", "IS-VER", "IS-5", "IS-AKH", "IS-BLO", "IS-HUT", "IS-HUV", "IS-SKG", "IS-SSF", "IS-SSS", "IS-6", "IS-AKU", "IS-DAV", "IS-EYF", "IS-FJL", "IS-GRY", "IS-HRG", "IS-LAN", "IS-NOR", "IS-SBH", "IS-SBT", "IS-SKU", "IS-THG", "IS-TJO", "IS-7", "IS-FJD", "IS-FLR", "IS-MUL", "IS-SHF", "IS-VOP", "IS-8", "IS-ASA", "IS-BLA", "IS-FLA", "IS-GOG", "IS-HRU", "IS-HVE", "IS-MYR", "IS-RGE", "IS-RGY", "IS-SFA", "IS-SKF", "IS-SOG", "IS-SOL", "IS-VEM", "IT-21", "IT-AL", "IT-AT", "IT-BI", "IT-CN", "IT-NO", "IT-VB", "IT-VC", "IT-TO", "IT-25", "IT-BG", "IT-BS", "IT-CO", "IT-CR", "IT-LC", "IT-LO", "IT-MB", "IT-MN", "IT-PV", "IT-SO", "IT-VA", "IT-MI", "IT-34", "IT-BL", "IT-PD", "IT-RO", "IT-TV", "IT-VI", "IT-VR", "IT-VE", "IT-42", "IT-IM", "IT-SP", "IT-SV", "IT-GE", "IT-45", "IT-FC", "IT-FE", "IT-MO", "IT-PC", "IT-PR", "IT-RA", "IT-RE", "IT-RN", "IT-BO", "IT-52", "IT-AR", "IT-GR", "IT-LI", "IT-LU", "IT-MS", "IT-PI", "IT-PO", "IT-PT", "IT-SI", "IT-FI", "IT-55", "IT-PG", "IT-TR", "IT-57", "IT-AN", "IT-AP", "IT-FM", "IT-MC", "IT-PU", "IT-62", "IT-FR", "IT-LT", "IT-RI", "IT-VT", "IT-RM", "IT-65", "IT-AQ", "IT-CH", "IT-PE", "IT-TE", "IT-67", "IT-CB", "IT-IS", "IT-72", "IT-AV", "IT-BN", "IT-CE", "IT-SA", "IT-NA", "IT-75", "IT-BR", "IT-BT", "IT-FG", "IT-LE", "IT-TA", "IT-BA", "IT-77", "IT-MT", "IT-PZ", "IT-78", "IT-CS", "IT-CZ", "IT-KR", "IT-VV", "IT-RC", "IT-23", "IT-32", "IT-BZ", "IT-TN", "IT-36", "IT-GO", "IT-PN", "IT-TS", "IT-UD", "IT-82", "IT-AG", "IT-CL", "IT-EN", "IT-RG", "IT-SR", "IT-TP", "IT-CT", "IT-ME", "IT-PA", "IT-88", "IT-NU", "IT-OR", "IT-SS", "IT-SU", "IT-CA", "JM-01", "JM-02", "JM-03", "JM-04", "JM-05", "JM-06", "JM-07", "JM-08", "JM-09", "JM-10", "JM-11", "JM-12", "JM-13", "JM-14", "JO-AJ", "JO-AM", "JO-AQ", "JO-AT", "JO-AZ", "JO-BA", "JO-IR", "JO-JA", "JO-KA", "JO-MA", "JO-MD", "JO-MN", "JP-01", "JP-02", "JP-03", "JP-04", "JP-05", "JP-06", "JP-07", "JP-08", "JP-09", "JP-10", "JP-11", "JP-12", "JP-13", "JP-14", "JP-15", "JP-16", "JP-17", "JP-18", "JP-19", "JP-20", "JP-21", "JP-22", "JP-23", "JP-24", "JP-25", "JP-26", "JP-27", "JP-28", "JP-29", "JP-30", "JP-31", "JP-32", "JP-33", "JP-34", "JP-35", "JP-36", "JP-37", "JP-38", "JP-39", "JP-40", "JP-41", "JP-42", "JP-43", "JP-44", "JP-45", "JP-46", "JP-47", "KE-01", "KE-02", "KE-03", "KE-04", "KE-05", "KE-06", "KE-07", "KE-08", "KE-09", "KE-10", "KE-11", "KE-12", "KE-13", "KE-14", "KE-15", "KE-16", "KE-17", "KE-18", "KE-19", "KE-20", "KE-21", "KE-22", "KE-23", "KE-24", "KE-25", "KE-26", "KE-27", "KE-28", "KE-29", "KE-30", "KE-31", "KE-32", "KE-33", "KE-34", "KE-35", "KE-36", "KE-37", "KE-38", "KE-39", "KE-40", "KE-41", "KE-42", "KE-43", "KE-44", "KE-45", "KE-46", "KE-47", "KG-GB", "KG-GO", "KG-B", "KG-C", "KG-J", "KG-N", "KG-O", "KG-T", "KG-Y", "KH-12", "KH-1", "KH-10", "KH-11", "KH-13", "KH-14", "KH-15", "KH-16", "KH-17", "KH-18", "KH-19", "KH-2", "KH-20", "KH-21", "KH-22", "KH-23", "KH-24", "KH-25", "KH-3", "KH-4", "KH-5", "KH-6", "KH-7", "KH-8", "KH-9", "KI-G", "KI-L", "KI-P", "KM-A", "KM-G", "KM-M", "KN-K", "KN-01", "KN-02", "KN-03", "KN-06", "KN-08", "KN-09", "KN-11", "KN-13", "KN-15", "KN-N", "KN-04", "KN-05", "KN-07", "KN-10", "KN-12", "KP-13", "KP-02", "KP-03", "KP-04", "KP-05", "KP-06", "KP-07", "KP-08", "KP-09", "KP-10", "KP-01", "KP-14", "KR-11", "KR-26", "KR-27", "KR-28", "KR-29", "KR-30", "KR-31", "KR-41", "KR-42", "KR-43", "KR-44", "KR-45", "KR-46", "KR-47", "KR-48", "KR-49", "KR-50", "KW-AH", "KW-FA", "KW-HA", "KW-JA", "KW-KU", "KW-MU", "KZ-AKM", "KZ-AKT", "KZ-ALM", "KZ-ATY", "KZ-KAR", "KZ-KUS", "KZ-KZY", "KZ-MAN", "KZ-PAV", "KZ-SEV", "KZ-VOS", "KZ-YUZ", "KZ-ZAP", "KZ-ZHA", "KZ-ALA", "KZ-AST", "KZ-SHY", "LA-VT", "LA-AT", "LA-BK", "LA-BL", "LA-CH", "LA-HO", "LA-KH", "LA-LM", "LA-LP", "LA-OU", "LA-PH", "LA-SL", "LA-SV", "LA-VI", "LA-XA", "LA-XE", "LA-XI", "LA-XS", "LB-AK", "LB-AS", "LB-BA", "LB-BH", "LB-BI", "LB-JA", "LB-JL", "LB-NA", "LC-01", "LC-02", "LC-03", "LC-05", "LC-06", "LC-07", "LC-08", "LC-10", "LC-11", "LC-12", "LI-01", "LI-02", "LI-03", "LI-04", "LI-05", "LI-06", "LI-07", "LI-08", "LI-09", "LI-10", "LI-11", "LK-1", "LK-11", "LK-12", "LK-13", "LK-2", "LK-21", "LK-22", "LK-23", "LK-3", "LK-31", "LK-32", "LK-33", "LK-4", "LK-41", "LK-42", "LK-43", "LK-44", "LK-45", "LK-5", "LK-51", "LK-52", "LK-53", "LK-6", "LK-61", "LK-62", "LK-7", "LK-71", "LK-72", "LK-8", "LK-81", "LK-82", "LK-9", "LK-91", "LK-92", "LR-BG", "LR-BM", "LR-CM", "LR-GB", "LR-GG", "LR-GK", "LR-GP", "LR-LO", "LR-MG", "LR-MO", "LR-MY", "LR-NI", "LR-RG", "LR-RI", "LR-SI", "LS-A", "LS-B", "LS-C", "LS-D", "LS-E", "LS-F", "LS-G", "LS-H", "LS-J", "LS-K", "LT-AL", "LT-02", "LT-03", "LT-24", "LT-55", "LT-07", "LT-KL", "LT-20", "LT-31", "LT-21", "LT-22", "LT-46", "LT-48", "LT-28", "LT-KU", "LT-15", "LT-10", "LT-13", "LT-16", "LT-18", "LT-36", "LT-38", "LT-05", "LT-MR", "LT-25", "LT-41", "LT-56", "LT-14", "LT-17", "LT-PN", "LT-32", "LT-06", "LT-23", "LT-33", "LT-34", "LT-40", "LT-SA", "LT-43", "LT-01", "LT-11", "LT-19", "LT-30", "LT-37", "LT-44", "LT-TA", "LT-12", "LT-45", "LT-50", "LT-29", "LT-TE", "LT-26", "LT-35", "LT-51", "LT-39", "LT-UT", "LT-04", "LT-09", "LT-27", "LT-54", "LT-60", "LT-59", "LT-VL", "LT-57", "LT-42", "LT-47", "LT-49", "LT-52", "LT-53", "LT-58", "LT-08", "LU-CA", "LU-CL", "LU-DI", "LU-EC", "LU-ES", "LU-GR", "LU-LU", "LU-ME", "LU-RD", "LU-RM", "LU-VD", "LU-WI", "LV-002", "LV-007", "LV-011", "LV-015", "LV-016", "LV-022", "LV-026", "LV-033", "LV-041", "LV-042", "LV-047", "LV-050", "LV-052", "LV-054", "LV-056", "LV-058", "LV-059", "LV-062", "LV-067", "LV-068", "LV-073", "LV-077", "LV-080", "LV-087", "LV-088", "LV-089", "LV-091", "LV-094", "LV-097", "LV-099", "LV-101", "LV-102", "LV-106", "LV-111", "LV-112", "LV-113", "LV-DGV", "LV-JEL", "LV-JUR", "LV-LPX", "LV-REZ", "LV-RIX", "LV-VEN", "LY-BA", "LY-BU", "LY-DR", "LY-GT", "LY-JA", "LY-JG", "LY-JI", "LY-JU", "LY-KF", "LY-MB", "LY-MI", "LY-MJ", "LY-MQ", "LY-NL", "LY-NQ", "LY-SB", "LY-SR", "LY-TB", "LY-WA", "LY-WD", "LY-WS", "LY-ZA", "MA-01", "MA-MDF", "MA-TNG", "MA-CHE", "MA-FAH", "MA-HOC", "MA-LAR", "MA-OUZ", "MA-TET", "MA-02", "MA-OUJ", "MA-BER", "MA-DRI", "MA-FIG", "MA-GUF", "MA-JRA", "MA-NAD", "MA-TAI", "MA-03", "MA-FES", "MA-MEK", "MA-BOM", "MA-HAJ", "MA-IFR", "MA-MOU", "MA-SEF", "MA-TAO", "MA-TAZ", "MA-04", "MA-RAB", "MA-SAL", "MA-SKH", "MA-KEN", "MA-KHE", "MA-NOU", "MA-SIK", "MA-SIL", "MA-05", "MA-AZI", "MA-BEM", "MA-FQH", "MA-KHN", "MA-KHO", "MA-06", "MA-CAS", "MA-MOH", "MA-BES", "MA-BRR", "MA-CHT", "MA-JDI", "MA-MED", "MA-SET", "MA-SIB", "MA-07", "MA-MAR", "MA-CHI", "MA-ESI", "MA-HAO", "MA-KES", "MA-REH", "MA-SAF", "MA-YUS", "MA-08", "MA-ERR", "MA-MID", "MA-OUA", "MA-TIN", "MA-ZAG", "MA-09", "MA-AGD", "MA-INE", "MA-TAR", "MA-TAT", "MA-TIZ", "MA-10", "MA-ASZ", "MA-GUE", "MA-SIF", "MA-TNT", "MA-11", "MA-BOD", "MA-ESM", "MA-LAA", "MA-TAF", "MA-12", "MA-AOU", "MA-OUD", "MC-CL", "MC-CO", "MC-FO", "MC-GA", "MC-JE", "MC-LA", "MC-MA", "MC-MC", "MC-MG", "MC-MO", "MC-MU", "MC-PH", "MC-SD", "MC-SO", "MC-SP", "MC-SR", "MC-VR", "MD-AN", "MD-BR", "MD-BS", "MD-CA", "MD-CL", "MD-CM", "MD-CR", "MD-CS", "MD-CT", "MD-DO", "MD-DR", "MD-DU", "MD-ED", "MD-FA", "MD-FL", "MD-GL", "MD-HI", "MD-IA", "MD-LE", "MD-NI", "MD-OC", "MD-OR", "MD-RE", "MD-RI", "MD-SD", "MD-SI", "MD-SO", "MD-ST", "MD-SV", "MD-TA", "MD-TE", "MD-UN", "MD-GA", "MD-SN", "MD-BA", "MD-BD", "MD-CU", "ME-01", "ME-02", "ME-03", "ME-04", "ME-05", "ME-06", "ME-07", "ME-08", "ME-09", "ME-10", "ME-11", "ME-12", "ME-13", "ME-14", "ME-15", "ME-16", "ME-17", "ME-18", "ME-19", "ME-20", "ME-21", "ME-22", "ME-23", "ME-24", "MG-A", "MG-D", "MG-F", "MG-M", "MG-T", "MG-U", "MH-L", "MH-ALL", "MH-EBO", "MH-ENI", "MH-JAB", "MH-JAL", "MH-KIL", "MH-KWA", "MH-LAE", "MH-LIB", "MH-NMK", "MH-NMU", "MH-RON", "MH-UJA", "MH-WTH", "MH-T", "MH-ALK", "MH-ARN", "MH-AUR", "MH-LIK", "MH-MAJ", "MH-MAL", "MH-MEJ", "MH-MIL", "MH-UTI", "MH-WTJ", "MK-101", "MK-102", "MK-103", "MK-104", "MK-105", "MK-106", "MK-107", "MK-108", "MK-109", "MK-201", "MK-202", "MK-203", "MK-204", "MK-205", "MK-206", "MK-207", "MK-208", "MK-209", "MK-210", "MK-211", "MK-301", "MK-303", "MK-304", "MK-307", "MK-308", "MK-310", "MK-311", "MK-312", "MK-313", "MK-401", "MK-402", "MK-403", "MK-404", "MK-405", "MK-406", "MK-407", "MK-408", "MK-409", "MK-410", "MK-501", "MK-502", "MK-503", "MK-504", "MK-505", "MK-506", "MK-507", "MK-508", "MK-509", "MK-601", "MK-602", "MK-603", "MK-604", "MK-605", "MK-606", "MK-607", "MK-608", "MK-609", "MK-701", "MK-702", "MK-703", "MK-704", "MK-705", "MK-706", "MK-801", "MK-802", "MK-803", "MK-804", "MK-805", "MK-806", "MK-807", "MK-808", "MK-809", "MK-810", "MK-811", "MK-812", "MK-813", "MK-814", "MK-815", "MK-816", "MK-817", "ML-1", "ML-10", "ML-2", "ML-3", "ML-4", "ML-5", "ML-6", "ML-7", "ML-8", "ML-9", "ML-BKO", "MM-01", "MM-02", "MM-03", "MM-04", "MM-05", "MM-06", "MM-07", "MM-11", "MM-12", "MM-13", "MM-14", "MM-15", "MM-16", "MM-17", "MM-18", "MN-035", "MN-037", "MN-039", "MN-041", "MN-043", "MN-046", "MN-047", "MN-049", "MN-051", "MN-053", "MN-055", "MN-057", "MN-059", "MN-061", "MN-063", "MN-064", "MN-065", "MN-067", "MN-069", "MN-071", "MN-073", "MN-1", "MR-01", "MR-02", "MR-03", "MR-04", "MR-05", "MR-06", "MR-07", "MR-08", "MR-09", "MR-10", "MR-11", "MR-12", "MR-13", "MR-14", "MR-15", "MT-01", "MT-02", "MT-03", "MT-04", "MT-05", "MT-06", "MT-07", "MT-08", "MT-09", "MT-10", "MT-11", "MT-12", "MT-13", "MT-14", "MT-15", "MT-16", "MT-17", "MT-18", "MT-19", "MT-20", "MT-21", "MT-22", "MT-23", "MT-24", "MT-25", "MT-26", "MT-27", "MT-28", "MT-29", "MT-30", "MT-31", "MT-32", "MT-33", "MT-34", "MT-35", "MT-36", "MT-37", "MT-38", "MT-39", "MT-40", "MT-41", "MT-42", "MT-43", "MT-44", "MT-45", "MT-46", "MT-47", "MT-48", "MT-49", "MT-50", "MT-51", "MT-52", "MT-53", "MT-54", "MT-55", "MT-56", "MT-57", "MT-58", "MT-59", "MT-60", "MT-61", "MT-62", "MT-63", "MT-64", "MT-65", "MT-66", "MT-67", "MT-68", "MU-BL", "MU-FL", "MU-GP", "MU-MO", "MU-PA", "MU-PL", "MU-PW", "MU-RR", "MU-SA", "MU-AG", "MU-CC", "MU-RO", "MV-00", "MV-02", "MV-03", "MV-04", "MV-05", "MV-07", "MV-08", "MV-12", "MV-13", "MV-14", "MV-17", "MV-20", "MV-23", "MV-24", "MV-25", "MV-26", "MV-27", "MV-28", "MV-29", "MV-01", "MV-MLE", "MW-C", "MW-DE", "MW-DO", "MW-KS", "MW-LI", "MW-MC", "MW-NI", "MW-NK", "MW-NU", "MW-SA", "MW-N", "MW-CT", "MW-KR", "MW-LK", "MW-MZ", "MW-NB", "MW-RU", "MW-S", "MW-BA", "MW-BL", "MW-CK", "MW-CR", "MW-MG", "MW-MH", "MW-MU", "MW-MW", "MW-NE", "MW-NS", "MW-PH", "MW-TH", "MW-ZO", "MX-AGU", "MX-BCN", "MX-BCS", "MX-CAM", "MX-CHH", "MX-CHP", "MX-COA", "MX-COL", "MX-DUR", "MX-GRO", "MX-GUA", "MX-HID", "MX-JAL", "MX-MEX", "MX-MIC", "MX-MOR", "MX-NAY", "MX-NLE", "MX-OAX", "MX-PUE", "MX-QUE", "MX-ROO", "MX-SIN", "MX-SLP", "MX-SON", "MX-TAB", "MX-TAM", "MX-TLA", "MX-VER", "MX-YUC", "MX-ZAC", "MX-CMX", "MY-14", "MY-15", "MY-16", "MY-01", "MY-02", "MY-03", "MY-04", "MY-05", "MY-06", "MY-07", "MY-08", "MY-09", "MY-10", "MY-11", "MY-12", "MY-13", "MZ-MPM", "MZ-A", "MZ-B", "MZ-G", "MZ-I", "MZ-L", "MZ-N", "MZ-P", "MZ-Q", "MZ-S", "MZ-T", "NA-CA", "NA-ER", "NA-HA", "NA-KA", "NA-KE", "NA-KH", "NA-KU", "NA-KW", "NA-OD", "NA-OH", "NA-ON", "NA-OS", "NA-OT", "NA-OW", "NE-8", "NE-1", "NE-2", "NE-3", "NE-4", "NE-5", "NE-6", "NE-7", "NG-AB", "NG-AD", "NG-AK", "NG-AN", "NG-BA", "NG-BE", "NG-BO", "NG-BY", "NG-CR", "NG-DE", "NG-EB", "NG-ED", "NG-EK", "NG-EN", "NG-GO", "NG-IM", "NG-JI", "NG-KD", "NG-KE", "NG-KN", "NG-KO", "NG-KT", "NG-KW", "NG-LA", "NG-NA", "NG-NI", "NG-OG", "NG-ON", "NG-OS", "NG-OY", "NG-PL", "NG-RI", "NG-SO", "NG-TA", "NG-YO", "NG-ZA", "NG-FC", "NI-BO", "NI-CA", "NI-CI", "NI-CO", "NI-ES", "NI-GR", "NI-JI", "NI-LE", "NI-MD", "NI-MN", "NI-MS", "NI-MT", "NI-NS", "NI-RI", "NI-SJ", "NI-AN", "NI-AS", "NL-DR", "NL-FL", "NL-FR", "NL-GE", "NL-GR", "NL-LI", "NL-NB", "NL-NH", "NL-OV", "NL-UT", "NL-ZE", "NL-ZH", "NL-AW", "NL-CW", "NL-SX", "NL-BQ1", "NL-BQ2", "NL-BQ3", "NO-03", "NO-11", "NO-15", "NO-18", "NO-30", "NO-34", "NO-38", "NO-42", "NO-46", "NO-50", "NO-54", "NO-21", "NO-22", "NP-1", "NP-BA", "NP-JA", "NP-NA", "NP-2", "NP-BH", "NP-KA", "NP-RA", "NP-3", "NP-DH", "NP-GA", "NP-LU", "NP-4", "NP-KO", "NP-ME", "NP-SA", "NP-5", "NP-MA", "NP-SE", "NP-P1", "NP-P2", "NP-P3", "NP-P4", "NP-P5", "NP-P6", "NP-P7", "NR-01", "NR-02", "NR-03", "NR-04", "NR-05", "NR-06", "NR-07", "NR-08", "NR-09", "NR-10", "NR-11", "NR-12", "NR-13", "NR-14", "NZ-AUK", "NZ-BOP", "NZ-CAN", "NZ-GIS", "NZ-HKB", "NZ-MBH", "NZ-MWT", "NZ-NSN", "NZ-NTL", "NZ-OTA", "NZ-STL", "NZ-TAS", "NZ-TKI", "NZ-WGN", "NZ-WKO", "NZ-WTC", "NZ-CIT", "OM-BJ", "OM-BS", "OM-BU", "OM-DA", "OM-MA", "OM-MU", "OM-SJ", "OM-SS", "OM-WU", "OM-ZA", "OM-ZU", "PA-EM", "PA-KY", "PA-NB", "PA-NT", "PA-1", "PA-10", "PA-2", "PA-3", "PA-4", "PA-5", "PA-6", "PA-7", "PA-8", "PA-9", "PE-AMA", "PE-ANC", "PE-APU", "PE-ARE", "PE-AYA", "PE-CAJ", "PE-CAL", "PE-CUS", "PE-HUC", "PE-HUV", "PE-ICA", "PE-JUN", "PE-LAL", "PE-LAM", "PE-LIM", "PE-LOR", "PE-MDD", "PE-MOQ", "PE-PAS", "PE-PIU", "PE-PUN", "PE-SAM", "PE-TAC", "PE-TUM", "PE-UCA", "PE-LMA", "PG-NCD", "PG-CPK", "PG-CPM", "PG-EBR", "PG-EHG", "PG-EPW", "PG-ESW", "PG-GPK", "PG-HLA", "PG-JWK", "PG-MBA", "PG-MPL", "PG-MPM", "PG-MRL", "PG-NIK", "PG-NPP", "PG-SAN", "PG-SHM", "PG-WBK", "PG-WHM", "PG-WPD", "PG-NSB", "PH-00", "PH-01", "PH-ILN", "PH-ILS", "PH-LUN", "PH-PAN", "PH-02", "PH-BTN", "PH-CAG", "PH-ISA", "PH-NUV", "PH-QUI", "PH-03", "PH-AUR", "PH-BAN", "PH-BUL", "PH-NUE", "PH-PAM", "PH-TAR", "PH-ZMB", "PH-05", "PH-ALB", "PH-CAN", "PH-CAS", "PH-CAT", "PH-MAS", "PH-SOR", "PH-06", "PH-AKL", "PH-ANT", "PH-CAP", "PH-GUI", "PH-ILI", "PH-NEC", "PH-07", "PH-BOH", "PH-CEB", "PH-NER", "PH-SIG", "PH-08", "PH-BIL", "PH-EAS", "PH-LEY", "PH-NSA", "PH-SLE", "PH-WSA", "PH-09", "PH-BAS", "PH-ZAN", "PH-ZAS", "PH-ZSI", "PH-10", "PH-BUK", "PH-CAM", "PH-MSC", "PH-MSR", "PH-11", "PH-COM", "PH-DAO", "PH-DAS", "PH-DAV", "PH-DVO", "PH-SAR", "PH-SCO", "PH-12", "PH-LAN", "PH-NCO", "PH-SUK", "PH-13", "PH-AGN", "PH-AGS", "PH-DIN", "PH-SUN", "PH-SUR", "PH-14", "PH-LAS", "PH-MAG", "PH-SLU", "PH-TAW", "PH-15", "PH-ABR", "PH-APA", "PH-BEN", "PH-IFU", "PH-KAL", "PH-MOU", "PH-40", "PH-BTG", "PH-CAV", "PH-LAG", "PH-QUE", "PH-RIZ", "PH-41", "PH-MAD", "PH-MDC", "PH-MDR", "PH-PLW", "PH-ROM", "PK-GB", "PK-JK", "PK-IS", "PK-BA", "PK-KP", "PK-PB", "PK-SD", "PL-02", "PL-04", "PL-06", "PL-08", "PL-10", "PL-12", "PL-14", "PL-16", "PL-18", "PL-20", "PL-22", "PL-24", "PL-26", "PL-28", "PL-30", "PL-32", "PS-BTH", "PS-DEB", "PS-GZA", "PS-HBN", "PS-JEM", "PS-JEN", "PS-JRH", "PS-KYS", "PS-NBS", "PS-NGZ", "PS-QQA", "PS-RBH", "PS-RFH", "PS-SLT", "PS-TBS", "PS-TKM", "PT-20", "PT-30", "PT-01", "PT-02", "PT-03", "PT-04", "PT-05", "PT-06", "PT-07", "PT-08", "PT-09", "PT-10", "PT-11", "PT-12", "PT-13", "PT-14", "PT-15", "PT-16", "PT-17", "PT-18", "PW-002", "PW-004", "PW-010", "PW-050", "PW-100", "PW-150", "PW-212", "PW-214", "PW-218", "PW-222", "PW-224", "PW-226", "PW-227", "PW-228", "PW-350", "PW-370", "PY-1", "PY-10", "PY-11", "PY-12", "PY-13", "PY-14", "PY-15", "PY-16", "PY-19", "PY-2", "PY-3", "PY-4", "PY-5", "PY-6", "PY-7", "PY-8", "PY-9", "PY-ASU", "QA-DA", "QA-KH", "QA-MS", "QA-RA", "QA-SH", "QA-US", "QA-WA", "QA-ZA", "RO-AB", "RO-AG", "RO-AR", "RO-BC", "RO-BH", "RO-BN", "RO-BR", "RO-BT", "RO-BV", "RO-BZ", "RO-CJ", "RO-CL", "RO-CS", "RO-CT", "RO-CV", "RO-DB", "RO-DJ", "RO-GJ", "RO-GL", "RO-GR", "RO-HD", "RO-HR", "RO-IF", "RO-IL", "RO-IS", "RO-MH", "RO-MM", "RO-MS", "RO-NT", "RO-OT", "RO-PH", "RO-SB", "RO-SJ", "RO-SM", "RO-SV", "RO-TL", "RO-TM", "RO-TR", "RO-VL", "RO-VN", "RO-VS", "RO-B", "RS-KM", "RS-25", "RS-26", "RS-27", "RS-28", "RS-29", "RS-VO", "RS-01", "RS-02", "RS-03", "RS-04", "RS-05", "RS-06", "RS-07", "RS-00", "RS-08", "RS-09", "RS-10", "RS-11", "RS-12", "RS-13", "RS-14", "RS-15", "RS-16", "RS-17", "RS-18", "RS-19", "RS-20", "RS-21", "RS-22", "RS-23", "RS-24", "RU-AMU", "RU-ARK", "RU-AST", "RU-BEL", "RU-BRY", "RU-CHE", "RU-IRK", "RU-IVA", "RU-KEM", "RU-KGD", "RU-KGN", "RU-KIR", "RU-KLU", "RU-KOS", "RU-KRS", "RU-LEN", "RU-LIP", "RU-MAG", "RU-MOS", "RU-MUR", "RU-NGR", "RU-NIZ", "RU-NVS", "RU-OMS", "RU-ORE", "RU-ORL", "RU-PNZ", "RU-PSK", "RU-ROS", "RU-RYA", "RU-SAK", "RU-SAM", "RU-SAR", "RU-SMO", "RU-SVE", "RU-TAM", "RU-TOM", "RU-TUL", "RU-TVE", "RU-TYU", "RU-ULY", "RU-VGG", "RU-VLA", "RU-VLG", "RU-VOR", "RU-YAR", "RU-CHU", "RU-KHM", "RU-NEN", "RU-YAN", "RU-MOW", "RU-SPE", "RU-YEV", "RU-ALT", "RU-KAM", "RU-KDA", "RU-KHA", "RU-KYA", "RU-PER", "RU-PRI", "RU-STA", "RU-ZAB", "RU-AD", "RU-AL", "RU-BA", "RU-BU", "RU-CE", "RU-CU", "RU-DA", "RU-IN", "RU-KB", "RU-KC", "RU-KK", "RU-KL", "RU-KO", "RU-KR", "RU-ME", "RU-MO", "RU-SA", "RU-SE", "RU-TA", "RU-TY", "RU-UD", "RW-01", "RW-02", "RW-03", "RW-04", "RW-05", "SA-01", "SA-02", "SA-03", "SA-04", "SA-05", "SA-06", "SA-07", "SA-08", "SA-09", "SA-10", "SA-11", "SA-12", "SA-14", "SB-CE", "SB-CH", "SB-GU", "SB-IS", "SB-MK", "SB-ML", "SB-RB", "SB-TE", "SB-WE", "SB-CT", "SC-01", "SC-02", "SC-03", "SC-04", "SC-05", "SC-06", "SC-07", "SC-08", "SC-09", "SC-10", "SC-11", "SC-12", "SC-13", "SC-14", "SC-15", "SC-16", "SC-17", "SC-18", "SC-19", "SC-20", "SC-21", "SC-22", "SC-23", "SC-24", "SC-25", "SC-26", "SC-27", "SD-DC", "SD-DE", "SD-DN", "SD-DS", "SD-DW", "SD-GD", "SD-GK", "SD-GZ", "SD-KA", "SD-KH", "SD-KN", "SD-KS", "SD-NB", "SD-NO", "SD-NR", "SD-NW", "SD-RS", "SD-SI", "SE-AB", "SE-AC", "SE-BD", "SE-C", "SE-D", "SE-E", "SE-F", "SE-G", "SE-H", "SE-I", "SE-K", "SE-M", "SE-N", "SE-O", "SE-S", "SE-T", "SE-U", "SE-W", "SE-X", "SE-Y", "SE-Z", "SG-01", "SG-02", "SG-03", "SG-04", "SG-05", "SH-AC", "SH-HL", "SH-TA", "SI-001", "SI-002", "SI-003", "SI-004", "SI-005", "SI-006", "SI-007", "SI-008", "SI-009", "SI-010", "SI-011", "SI-012", "SI-013", "SI-014", "SI-015", "SI-016", "SI-017", "SI-018", "SI-019", "SI-020", "SI-021", "SI-022", "SI-023", "SI-024", "SI-025", "SI-026", "SI-027", "SI-028", "SI-029", "SI-030", "SI-031", "SI-032", "SI-033", "SI-034", "SI-035", "SI-036", "SI-037", "SI-038", "SI-039", "SI-040", "SI-041", "SI-042", "SI-043", "SI-044", "SI-045", "SI-046", "SI-047", "SI-048", "SI-049", "SI-050", "SI-051", "SI-052", "SI-053", "SI-054", "SI-055", "SI-056", "SI-057", "SI-058", "SI-059", "SI-060", "SI-061", "SI-062", "SI-063", "SI-064", "SI-065", "SI-066", "SI-067", "SI-068", "SI-069", "SI-070", "SI-071", "SI-072", "SI-073", "SI-074", "SI-075", "SI-076", "SI-077", "SI-078", "SI-079", "SI-080", "SI-081", "SI-082", "SI-083", "SI-084", "SI-085", "SI-086", "SI-087", "SI-088", "SI-089", "SI-090", "SI-091", "SI-092", "SI-093", "SI-094", "SI-095", "SI-096", "SI-097", "SI-098", "SI-099", "SI-100", "SI-101", "SI-102", "SI-103", "SI-104", "SI-105", "SI-106", "SI-107", "SI-108", "SI-109", "SI-110", "SI-111", "SI-112", "SI-113", "SI-114", "SI-115", "SI-116", "SI-117", "SI-118", "SI-119", "SI-120", "SI-121", "SI-122", "SI-123", "SI-124", "SI-125", "SI-126", "SI-127", "SI-128", "SI-129", "SI-130", "SI-131", "SI-132", "SI-133", "SI-134", "SI-135", "SI-136", "SI-137", "SI-138", "SI-139", "SI-140", "SI-141", "SI-142", "SI-143", "SI-144", "SI-146", "SI-147", "SI-148", "SI-149", "SI-150", "SI-151", "SI-152", "SI-153", "SI-154", "SI-155", "SI-156", "SI-157", "SI-158", "SI-159", "SI-160", "SI-161", "SI-162", "SI-163", "SI-164", "SI-165", "SI-166", "SI-167", "SI-168", "SI-169", "SI-170", "SI-171", "SI-172", "SI-173", "SI-174", "SI-175", "SI-176", "SI-177", "SI-178", "SI-179", "SI-180", "SI-181", "SI-182", "SI-183", "SI-184", "SI-185", "SI-186", "SI-187", "SI-188", "SI-189", "SI-190", "SI-191", "SI-192", "SI-193", "SI-194", "SI-195", "SI-196", "SI-197", "SI-198", "SI-199", "SI-200", "SI-201", "SI-202", "SI-203", "SI-204", "SI-205", "SI-206", "SI-207", "SI-208", "SI-209", "SI-210", "SI-211", "SI-212", "SI-213", "SK-BC", "SK-BL", "SK-KI", "SK-NI", "SK-PV", "SK-TA", "SK-TC", "SK-ZI", "SL-W", "SL-E", "SL-N", "SL-NW", "SL-S", "SM-01", "SM-02", "SM-03", "SM-04", "SM-05", "SM-06", "SM-07", "SM-08", "SM-09", "SN-DB", "SN-DK", "SN-FK", "SN-KA", "SN-KD", "SN-KE", "SN-KL", "SN-LG", "SN-MT", "SN-SE", "SN-SL", "SN-TC", "SN-TH", "SN-ZG", "SO-AW", "SO-BK", "SO-BN", "SO-BR", "SO-BY", "SO-GA", "SO-GE", "SO-HI", "SO-JD", "SO-JH", "SO-MU", "SO-NU", "SO-SA", "SO-SD", "SO-SH", "SO-SO", "SO-TO", "SO-WO", "SR-BR", "SR-CM", "SR-CR", "SR-MA", "SR-NI", "SR-PM", "SR-PR", "SR-SA", "SR-SI", "SR-WA", "SS-BN", "SS-BW", "SS-EC", "SS-EE", "SS-EW", "SS-JG", "SS-LK", "SS-NU", "SS-UY", "SS-WR", "ST-P", "ST-01", "ST-02", "ST-03", "ST-04", "ST-05", "ST-06", "SV-AH", "SV-CA", "SV-CH", "SV-CU", "SV-LI", "SV-MO", "SV-PA", "SV-SA", "SV-SM", "SV-SO", "SV-SS", "SV-SV", "SV-UN", "SV-US", "SY-DI", "SY-DR", "SY-DY", "SY-HA", "SY-HI", "SY-HL", "SY-HM", "SY-ID", "SY-LA", "SY-QU", "SY-RA", "SY-RD", "SY-SU", "SY-TA", "SZ-HH", "SZ-LU", "SZ-MA", "SZ-SH", "TD-BA", "TD-BG", "TD-BO", "TD-CB", "TD-EE", "TD-EO", "TD-GR", "TD-HL", "TD-KA", "TD-LC", "TD-LO", "TD-LR", "TD-MA", "TD-MC", "TD-ME", "TD-MO", "TD-ND", "TD-OD", "TD-SA", "TD-SI", "TD-TA", "TD-TI", "TD-WF", "TG-C", "TG-K", "TG-M", "TG-P", "TG-S", "TH-10", "TH-11", "TH-12", "TH-13", "TH-14", "TH-15", "TH-16", "TH-17", "TH-18", "TH-19", "TH-20", "TH-21", "TH-22", "TH-23", "TH-24", "TH-25", "TH-26", "TH-27", "TH-30", "TH-31", "TH-32", "TH-33", "TH-34", "TH-35", "TH-36", "TH-37", "TH-38", "TH-39", "TH-40", "TH-41", "TH-42", "TH-43", "TH-44", "TH-45", "TH-46", "TH-47", "TH-48", "TH-49", "TH-50", "TH-51", "TH-52", "TH-53", "TH-54", "TH-55", "TH-56", "TH-57", "TH-58", "TH-60", "TH-61", "TH-62", "TH-63", "TH-64", "TH-65", "TH-66", "TH-67", "TH-70", "TH-71", "TH-72", "TH-73", "TH-74", "TH-75", "TH-76", "TH-77", "TH-80", "TH-81", "TH-82", "TH-83", "TH-84", "TH-85", "TH-86", "TH-90", "TH-91", "TH-92", "TH-93", "TH-94", "TH-95", "TH-96", "TH-S", "TJ-KT", "TJ-SU", "TJ-GB", "TJ-DU", "TJ-RA", "TL-AL", "TL-AN", "TL-BA", "TL-BO", "TL-CO", "TL-DI", "TL-ER", "TL-LA", "TL-LI", "TL-MF", "TL-MT", "TL-VI", "TL-OE", "TM-S", "TM-A", "TM-B", "TM-D", "TM-L", "TM-M", "TN-11", "TN-12", "TN-13", "TN-14", "TN-21", "TN-22", "TN-23", "TN-31", "TN-32", "TN-33", "TN-34", "TN-41", "TN-42", "TN-43", "TN-51", "TN-52", "TN-53", "TN-61", "TN-71", "TN-72", "TN-73", "TN-81", "TN-82", "TN-83", "TO-01", "TO-02", "TO-03", "TO-04", "TO-05", "TR-01", "TR-02", "TR-03", "TR-04", "TR-05", "TR-06", "TR-07", "TR-08", "TR-09", "TR-10", "TR-11", "TR-12", "TR-13", "TR-14", "TR-15", "TR-16", "TR-17", "TR-18", "TR-19", "TR-20", "TR-21", "TR-22", "TR-23", "TR-24", "TR-25", "TR-26", "TR-27", "TR-28", "TR-29", "TR-30", "TR-31", "TR-32", "TR-33", "TR-34", "TR-35", "TR-36", "TR-37", "TR-38", "TR-39", "TR-40", "TR-41", "TR-42", "TR-43", "TR-44", "TR-45", "TR-46", "TR-47", "TR-48", "TR-49", "TR-50", "TR-51", "TR-52", "TR-53", "TR-54", "TR-55", "TR-56", "TR-57", "TR-58", "TR-59", "TR-60", "TR-61", "TR-62", "TR-63", "TR-64", "TR-65", "TR-66", "TR-67", "TR-68", "TR-69", "TR-70", "TR-71", "TR-72", "TR-73", "TR-74", "TR-75", "TR-76", "TR-77", "TR-78", "TR-79", "TR-80", "TR-81", "TT-ARI", "TT-CHA", "TT-PTF", "TT-CTT", "TT-DMN", "TT-MRC", "TT-PED", "TT-PRT", "TT-SGE", "TT-SIP", "TT-SJL", "TT-TUP", "TT-TOB", "TT-POS", "TT-SFO", "TV-NIT", "TV-NKF", "TV-NKL", "TV-NMA", "TV-NMG", "TV-NUI", "TV-VAI", "TV-FUN", "TW-CYI", "TW-HSZ", "TW-KEE", "TW-KHH", "TW-NWT", "TW-TAO", "TW-TNN", "TW-TPE", "TW-TXG", "TW-CHA", "TW-CYQ", "TW-HSQ", "TW-HUA", "TW-ILA", "TW-KIN", "TW-LIE", "TW-MIA", "TW-NAN", "TW-PEN", "TW-PIF", "TW-TTT", "TW-YUN", "TZ-01", "TZ-02", "TZ-03", "TZ-04", "TZ-05", "TZ-06", "TZ-07", "TZ-08", "TZ-09", "TZ-10", "TZ-11", "TZ-12", "TZ-13", "TZ-14", "TZ-15", "TZ-16", "TZ-17", "TZ-18", "TZ-19", "TZ-20", "TZ-21", "TZ-22", "TZ-23", "TZ-24", "TZ-25", "TZ-26", "TZ-27", "TZ-28", "TZ-29", "TZ-30", "TZ-31", "UA-30", "UA-40", "UA-05", "UA-07", "UA-09", "UA-12", "UA-14", "UA-18", "UA-21", "UA-23", "UA-26", "UA-32", "UA-35", "UA-46", "UA-48", "UA-51", "UA-53", "UA-56", "UA-59", "UA-61", "UA-63", "UA-65", "UA-68", "UA-71", "UA-74", "UA-77", "UA-43", "UG-C", "UG-101", "UG-103", "UG-104", "UG-105", "UG-106", "UG-107", "UG-108", "UG-109", "UG-110", "UG-111", "UG-112", "UG-113", "UG-114", "UG-115", "UG-116", "UG-117", "UG-118", "UG-119", "UG-120", "UG-121", "UG-122", "UG-123", "UG-124", "UG-125", "UG-126", "UG-102", "UG-E", "UG-201", "UG-202", "UG-203", "UG-204", "UG-205", "UG-206", "UG-207", "UG-208", "UG-209", "UG-210", "UG-211", "UG-212", "UG-213", "UG-214", "UG-215", "UG-216", "UG-217", "UG-218", "UG-219", "UG-220", "UG-221", "UG-222", "UG-223", "UG-224", "UG-225", "UG-226", "UG-227", "UG-228", "UG-229", "UG-230", "UG-231", "UG-232", "UG-233", "UG-234", "UG-235", "UG-236", "UG-237", "UG-N", "UG-301", "UG-302", "UG-303", "UG-304", "UG-305", "UG-306", "UG-307", "UG-308", "UG-309", "UG-310", "UG-311", "UG-312", "UG-313", "UG-314", "UG-315", "UG-316", "UG-317", "UG-318", "UG-319", "UG-320", "UG-321", "UG-322", "UG-323", "UG-324", "UG-325", "UG-326", "UG-327", "UG-328", "UG-329", "UG-330", "UG-331", "UG-332", "UG-333", "UG-334", "UG-335", "UG-336", "UG-337", "UG-W", "UG-401", "UG-402", "UG-403", "UG-404", "UG-405", "UG-406", "UG-407", "UG-408", "UG-409", "UG-410", "UG-411", "UG-412", "UG-413", "UG-414", "UG-415", "UG-416", "UG-417", "UG-418", "UG-419", "UG-420", "UG-421", "UG-422", "UG-423", "UG-424", "UG-425", "UG-426", "UG-427", "UG-428", "UG-429", "UG-430", "UG-431", "UG-432", "UG-433", "UG-434", "UG-435", "UM-67", "UM-71", "UM-76", "UM-79", "UM-81", "UM-84", "UM-86", "UM-89", "UM-95", "US-DC", "US-AS", "US-GU", "US-MP", "US-PR", "US-UM", "US-VI", "US-AK", "US-AL", "US-AR", "US-AZ", "US-CA", "US-CO", "US-CT", "US-DE", "US-FL", "US-GA", "US-HI", "US-IA", "US-ID", "US-IL", "US-IN", "US-KS", "US-KY", "US-LA", "US-MA", "US-MD", "US-ME", "US-MI", "US-MN", "US-MO", "US-MS", "US-MT", "US-NC", "US-ND", "US-NE", "US-NH", "US-NJ", "US-NM", "US-NV", "US-NY", "US-OH", "US-OK", "US-OR", "US-PA", "US-RI", "US-SC", "US-SD", "US-TN", "US-TX", "US-UT", "US-VA", "US-VT", "US-WA", "US-WI", "US-WV", "US-WY", "UY-AR", "UY-CA", "UY-CL", "UY-CO", "UY-DU", "UY-FD", "UY-FS", "UY-LA", "UY-MA", "UY-MO", "UY-PA", "UY-RN", "UY-RO", "UY-RV", "UY-SA", "UY-SJ", "UY-SO", "UY-TA", "UY-TT", "UZ-AN", "UZ-BU", "UZ-FA", "UZ-JI", "UZ-NG", "UZ-NW", "UZ-QA", "UZ-SA", "UZ-SI", "UZ-SU", "UZ-TO", "UZ-XO", "UZ-QR", "UZ-TK", "VC-01", "VC-02", "VC-03", "VC-04", "VC-05", "VC-06", "VE-B", "VE-C", "VE-D", "VE-E", "VE-F", "VE-G", "VE-H", "VE-I", "VE-J", "VE-K", "VE-L", "VE-M", "VE-N", "VE-O", "VE-P", "VE-R", "VE-S", "VE-T", "VE-U", "VE-V", "VE-X", "VE-Y", "VE-Z", "VE-W", "VE-A", "VN-01", "VN-02", "VN-03", "VN-04", "VN-05", "VN-06", "VN-07", "VN-09", "VN-13", "VN-14", "VN-18", "VN-20", "VN-21", "VN-22", "VN-23", "VN-24", "VN-25", "VN-26", "VN-27", "VN-28", "VN-29", "VN-30", "VN-31", "VN-32", "VN-33", "VN-34", "VN-35", "VN-36", "VN-37", "VN-39", "VN-40", "VN-41", "VN-43", "VN-44", "VN-45", "VN-46", "VN-47", "VN-49", "VN-50", "VN-51", "VN-52", "VN-53", "VN-54", "VN-55", "VN-56", "VN-57", "VN-58", "VN-59", "VN-61", "VN-63", "VN-66", "VN-67", "VN-68", "VN-69", "VN-70", "VN-71", "VN-72", "VN-73", "VN-CT", "VN-DN", "VN-HN", "VN-HP", "VN-SG", "VU-MAP", "VU-PAM", "VU-SAM", "VU-SEE", "VU-TAE", "VU-TOB", "WF-AL", "WF-SG", "WF-UV", "WS-AA", "WS-AL", "WS-AT", "WS-FA", "WS-GE", "WS-GI", "WS-PA", "WS-SA", "WS-TU", "WS-VF", "WS-VS", "YE-SA", "YE-AB", "YE-AD", "YE-AM", "YE-BA", "YE-DA", "YE-DH", "YE-HD", "YE-HJ", "YE-HU", "YE-IB", "YE-JA", "YE-LA", "YE-MA", "YE-MR", "YE-MW", "YE-RA", "YE-SD", "YE-SH", "YE-SN", "YE-SU", "YE-TA", "ZA-EC", "ZA-FS", "ZA-GP", "ZA-KZN", "ZA-LP", "ZA-MP", "ZA-NC", "ZA-NW", "ZA-WC", "ZM-01", "ZM-02", "ZM-03", "ZM-04", "ZM-05", "ZM-06", "ZM-07", "ZM-08", "ZM-09", "ZM-10", "ZW-BU", "ZW-HA", "ZW-MA", "ZW-MC", "ZW-ME", "ZW-MI", "ZW-MN", "ZW-MS", "ZW-MV", "ZW-MW"] \ No newline at end of file diff --git a/ipdata/geofeeds.py b/ipdata/geofeeds.py new file mode 100644 index 0000000..38c7969 --- /dev/null +++ b/ipdata/geofeeds.py @@ -0,0 +1,192 @@ +""" + Geofeeds classes used in the CLI for the validator. +""" +import requests +import mmh3 +import csv +import ipaddress +import json +import logging + +from pathlib import Path +from rich.logging import RichHandler + +from codes import COUNTRIES, REGION_CODES + +FORMAT = "%(message)s" +logging.basicConfig( + level="ERROR", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] +) +log = logging.getLogger("rich") + +pwd = Path(__file__).parent.resolve() + + +class GeofeedValidationError(Exception): + pass + + +class Geofeed(object): + """ + Create an instance of a Geofeed object. + + :param source: Either a URL or a local file containing geofeed formatted data according to https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-05. + :param dir: The directory where the downloaded copy of a geofeed is cached + """ + + def __init__(self, source, dir="/tmp") -> None: + self.source = source + self.dir = dir + self.cache_path = None + self.total_count = 0 + self.valid_count = 0 + # Ensure uniqueness https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-05#appendix-A + self.prefixes = set() + + def _download(self): + """ + Downloads and caches the geofeed if a valid URL is provided + + :raises Exception: if a failure occurs when downloading the geofeed + """ + url_hash = mmh3.hash( + self.source, 42, signed=False + ) # 42 is the seed so that we get the same hashes on different devices, signed is so that we don't get a negative number + cache_path = f"{pwd}/{self.dir}/{url_hash}.csv" + + try: + response = requests.get(self.source, timeout=60) + text = response.text + with open(cache_path, "w") as f: + f.write( + "\n".join( + line for line in text.split("\n") if not line.startswith("#") + ) + ) # don't write comments + except Exception: + log.exception(f"Failed to get {self.source}") + else: + # set cache path if download was successful + self.cache_path = cache_path + + def entries(self): + """ + Reads each line in a geofeed and yields Entry objects, one for each line. + + :raises GeofeedValidationError: if it find a duplicate or if the expected CSV format is invalid eg. if the line has != 5 columns. There are a number of optional fields however the minimum number of commans should be present i.e. 4. + Also note that though the RFC recommends simply discarding extra columns to allow for additional fields in the future, we strictly check for 5 columns to prevent breaking out CSV parsing. + :yields: An Entry object + """ + # set path to the source by default + path = self.source + + # if the source is a url and we haven't already download it, try to download it and update path to the location of the download cache + if "://" in self.source and not self.cache_path: + self._download() + path = self.cache_path + + # if path still contains a url it means the download was not successful in which case we yield nothing! + if "://" in path: + log.warning(f"No entries found {self.source}") + yield + + with open(path) as f: + reader = csv.reader(f) + for entry in reader: + # keep count of the number of entries + self.total_count += 1 + + # generate error if there are not exactly 5 fields + if len(entry) != 5: + yield GeofeedValidationError( + f"[{entry}] is not a valid geofeed entry. The 'IP Range' and 'Country' fields are required however you must have the requisite minimum number of commas (currently 4) present" + ) + + # instantiate an Entry object + geofeed_entry = Entry(*entry) + + # check for duplicates and generate error if found + if geofeed_entry.ip_range in self.prefixes: + yield GeofeedValidationError( + f"Duplicate prefixes found {geofeed_entry.ip_range}" + ) + + self.prefixes.add(geofeed_entry.ip_range) + + # generate entry + yield geofeed_entry + + +class Entry(object): + """ + Create an instance of an Entry object + + :param ip_range: A valid IP address prefix + :param country: A valid ISO 3166-1 alpha 2 country code + :param region: A valid ISO 3166-1 alpha 2 subdivision code + :param city: The city where the IP range is allocated + :param postal_code: The postal code of the location the IP range is allocated + """ + + def __init__( + self, ip_range, country, region=None, city=None, postal_code=None + ) -> None: + self.ip_range = ip_range + self.country = country.upper() + self.region = region + self.city = city + self.postal_code = postal_code + self.row = [ + self.ip_range, + self.country, + self.region, + self.city, + self.postal_code, + ] + + def __str__(self) -> str: + return f"{self.ip_range},{self.country},{self.region},{self.city},{self.postal_code}" + + def validate(self): + """ + Validation rules for geofeed entries. Note that though the spec states that all fields other than the IP range are optional, we require at the minimum at country code. Entries without a country code will not pass validation. + + :raises GeofeedValidationError: if any of the validation rules are violated + """ + + # 1. Each IP range field MUST be either a single IP address or an IP prefix in CIDR notation in conformance with section 3.1 of[RFC4632] for IPv4 or section 2.3 of [RFC4291] for IPv6. + # Reference: https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02#section-2.1.1.1 + + try: + ipaddress.ip_network(self.ip_range) + except ValueError: + raise GeofeedValidationError( + f"[{self}] does not have a valid IP address or prefix" + ) + + # 2 (a) Country code must be present at a minimum + + if not self.country: + raise GeofeedValidationError(f"[{self}] is missing a country code") + + # 2 (b) Country code must be a valid 2-letter ISO 3166-1 alpha 2 country code + # Reference: https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02#section-2.1.1.2 + if not self.country in COUNTRIES: + raise GeofeedValidationError( + f"[{self}] ({self.country}) is not a valid ISO 3166-1 alpha 2 subdivision code" + ) + + # The region code is optional, but if it exists we need to validate it + if self.region: + # 3 (a) The region field, if non-empty, MUST be a ISO region code conforming to ISO 3166-2 [ISO.3166.2]. Parsers SHOULD treat this field case-insensitively. + # Reference: https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02#section-2.1.1.3 + if not self.region in REGION_CODES: + raise GeofeedValidationError( + f"[{self}] ({self.region}) is not a valid ISO 3166-1 alpha 2 region code" + ) + + # 3 (b) The region must be in the same country + if not self.region.split("-")[0] == self.country: + raise GeofeedValidationError( + f"[{self}] the region code provided is in a different country" + ) diff --git a/ipdata/ipdata.py b/ipdata/ipdata.py index 631090f..af837d3 100644 --- a/ipdata/ipdata.py +++ b/ipdata/ipdata.py @@ -1,100 +1,261 @@ -"""Call the https://api.ipdata.co API from Python.""" +"""This is the official IPData client library for Python. + +IPData client libraries allow for looking up the geolocation, +ownership and threat profile or any IP address and ASN. + +Example: + >>> from ipdata import IPData + >>> ipdata = IPData("YOUR API KEY") + >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") + +Alternatively thanks to the convenience methods in __init__.py you can do + +Example + >>> import ipdata + >>> ipdata.api_key = + >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") + +:class:`~.IPData` is the primary class for making API requests. +""" import ipaddress import requests +import logging +from requests.adapters import HTTPAdapter, Retry +from rich.logging import RichHandler -class APIKeyNotSet(Exception): - pass +FORMAT = "%(message)s" +logging.basicConfig( + level="ERROR", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] +) -class IncompatibleParameters(Exception): +class IPDataException(Exception): pass -class IPData: - base_url = 'https://api.ipdata.co/' - bulk_url = 'https://api.ipdata.co/bulk' - valid_fields = {'ip', 'is_eu', 'city', 'region', 'region_code', 'country_name', 'country_code', 'continent_name', - 'continent_code', 'latitude', 'longitude', 'asn', 'postal', 'calling_code', 'flag', - 'emoji_flag', 'emoji_unicode', 'carrier', 'languages', 'currency', 'time_zone', 'threat', 'count', - 'status', 'company'} +class DotDict(dict): + """ + dot.notation access to dictionary attributes + Based on https://stackoverflow.com/a/23689767/3176550 + """ + + def __getattr__(*args): + val = dict.get(*args) + if type(val) is dict: + return DotDict(val) + elif type(val) is list: + return [DotDict(item) for item in val] + return val + + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + +class IPData(object): + """ + Instances of IPData are used to make API requests. To make requests to the EU endpoint, set "endpoint" to "https://eu-api.ipdata.co". + + :param api_key: A valid IPData API key + :param endpoint: The API endpoint to send requests to + :param timeout: The default requests timeout + :param retry_limit: The maximum number of retries in case of failure + :param retry_backoff_factor: The number of seconds to sleep in between retries. With a 1 second backoff calls with be retried up to 7 times at the following intervals: 0.5, 1, 2, 4, 8, 16, 32 + :param debug: A boolean used to set the log level. Set to True when debugging. + """ - def __init__(self, api_key): - if not api_key: - raise APIKeyNotSet("Missing API Key") + log = logging.getLogger("rich") + + valid_fields = { + "ip", + "is_eu", + "city", + "region", + "region_code", + "country_name", + "country_code", + "continent_name", + "continent_code", + "latitude", + "longitude", + "asn", + "postal", + "calling_code", + "flag", + "emoji_flag", + "emoji_unicode", + "carrier", + "languages", + "currency", + "time_zone", + "threat", + "count", + "status", + "company", + } + + def __init__( + self, + api_key, + endpoint="https://api.ipdata.co/", + timeout=60, + retry_limit=7, + retry_backoff_factor=1, + debug=False, + ): + # Request settings self.api_key = api_key - self.headers = {'user-agent': 'ipdata-pypi'} + self.endpoint = endpoint.rstrip("/") # remove trailing / + self._timeout = timeout + self._headers = {"user-agent": "ipdata-python"} # set default UserAgent + self._query_params = {"api-key": self.api_key} + + # Enable debugging + if debug: + self.log.setLevel(logging.DEBUG) + + # Retry settings + retries = Retry( + total=retry_limit, + backoff_factor=retry_backoff_factor, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["POST", "GET"], + ) + adapter = HTTPAdapter(max_retries=retries) + + self._session = requests.Session() + self._session.mount("http", adapter) + + def _validate_fields(self, fields): + """ + Validates the fields passed in by the user, first ensuring it's a collection. In prior versions 'fields' was a string, however it now needs to be a collection. - def _validate_fields(self, select_field=None, fields=None): - if fields is None: - fields = [] + :param fields: A collection of supported fields + :raises ValueError: if 'fields' is not one of ['list', 'tuple' or 'set'] or if 'fields' contains unsupported fields + """ + if not type(fields) in [list, set, tuple]: + raise ValueError("'fields' must be of type 'list', 'tuple' or 'set'.") - if select_field and select_field not in self.valid_fields: - raise ValueError(f"{select_field} is not a valid field.") - if fields: - if not isinstance(fields, list): - raise ValueError('"fields" should be a list.') - for field in fields: - if field not in self.valid_fields: - raise ValueError(f"{field} is not a valid field.") + # Get all unsupported fields + diff = set(fields).difference(self.valid_fields) + if diff: + raise ValueError( + f"The field(s) {diff} are not supported. Only {self.valid_fields} are supported." + ) def _validate_ip_address(self, ip): + """ + Checks that 'ip' is a valid IP Address. + + :param ip: A string + :raises ValueError: if 'ip' is either not a valid IP address or is a reserved IP address eg. private, reserved or multicast + """ + request_ip = ipaddress.ip_address(ip) + if request_ip.is_private or request_ip.is_reserved or request_ip.is_multicast: + raise ValueError(f"{ip} is a reserved IP Address") + + def lookup(self, resource="", fields=[]): + """ + Makes a GET request to the IPData API for the specified 'resource' and the given 'fields'. + + :param resource: Either an IP address or an ASN prefixed by "AS" eg. "AS15169" + :param fields: A collection of API fields to be returned + + :returns: An API response as a DotDict object to allow dot notation access of fields eg. data.ip, data.company.name, data.threat.blocklists[0].name etc + + :raises IPDataException: if the API call fails or if there is a failure in decoding the response. + :raises ValueError: if 'resource' is not a string + """ + if type(resource) is not str: + raise ValueError(f"{resource} must be of type 'str'") + + resource = ( + resource.upper() + ) # capitalize resource in case it's a typoed ASN query eg. as2 + + if resource and not resource.startswith("AS"): + self._validate_ip_address(resource) + + self._validate_fields(fields) + query_params = self._query_params | {"fields": ",".join(fields)} + + # Make the request try: - ipaddress.ip_address(ip) - except Exception: - raise - if ipaddress.ip_address(ip).is_private: - raise ValueError(f"{ip} is a private IP Address") - - def lookup(self, ip=None, select_field=None, fields=None): - if fields is None: - fields = [] - - query = "" - query_params = {'api-key': self.api_key} - if ip: - self._validate_ip_address(ip) - query += f"{ip}/" - if select_field and fields: - raise IncompatibleParameters( - "The \"select_field\" and \"fields\" parameters cannot be used at the same time.") - if select_field: - self._validate_fields(select_field=select_field) - query += f"{select_field}/" - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.get(f"{self.base_url}{query}", headers=self.headers, params=query_params) + response = self._session.get( + f"{self.endpoint}/{resource}", + headers=self._headers, + params=query_params, + timeout=self._timeout, + ) + except Exception as e: + raise IPDataException(e) + status_code = response.status_code - if select_field and status_code == 200: - try: - response = {select_field: response.json(), 'status': status_code} - return response - except Exception: - response = {select_field: response.text, 'status': status_code} - return response - response = response.json() - response['status'] = status_code - return response - - def bulk_lookup(self, ips=None, fields=None): - if ips is None: - ips = [] - if fields is None: - fields = [] - - query_params = {'api-key': self.api_key} - if len(ips) < 2: - raise ValueError('Bulk Lookup requires more than 1 IP Address in the payload.') - if fields: - self._validate_fields(fields=fields) - query_params['fields'] = ','.join(fields) - response = requests.post(f"{self.bulk_url}", headers=self.headers, params=query_params, json=ips) + + # Decode the response and add metadata + try: + data = DotDict(response.json()) + data["status"] = status_code + except ValueError: + raise IPDataException( + f"An error occured while decoding the API response: {response.text}" + ) + + return data + + def bulk(self, resources, fields=[]): + """ + Lookup up to 100 resources in one request. Makes a POST request wth the resources as a JSON array and the specified fields. + + :param resources: A list of resources. This can be a list of IP addresses or ASNs or a mix of both which is not recommended unless you handle it during parsing. Mixing resources might lead to weird behavior when writing results to CSV. + :param fields: A collection of API fields to be returned. + + :returns: A DotDict object with the data contained under the 'responses' key. + + :raises ValueError: if resources it not a collection + :raises ValueError: if resources contains 0 items + :raises IPDataException: if the API call fails or if there is an error decoding the response + """ + + if type(resources) not in [list, set]: + raise ValueError(f"{resources} must be of type 'list' or 'set'") + + if len(resources) < 1: + raise ValueError("Bulk lookups must contain at least 1 resource.") + + self._validate_fields(fields) + query_params = self._query_params | {"fields": ",".join(fields)} + + # Make the requests + try: + response = self._session.post( + f"{self.endpoint}/bulk", + headers=self._headers, + params=query_params, + json=resources, + ) + except Exception as e: + raise IPDataException(f"Error when looking up {resources} - {e}") + status_code = response.status_code + + # Decode the response + try: + data = response.json() + except ValueError: + raise IPDataException( + f"An error occured while decoding the API response: {response.text}" + ) + + # If the request returned a non 200 response we add metadata and return the response if not status_code == 200: - response = response.json() - response['status'] = status_code - return response - response = {'responses': response.json(), 'status': status_code} - return response + data["status"] = status_code + return data + + return DotDict( + { + "responses": [DotDict(resource) for resource in data], + "status": status_code, + } + ) diff --git a/ipdata/lolcat.py b/ipdata/lolcat.py new file mode 100644 index 0000000..424d034 --- /dev/null +++ b/ipdata/lolcat.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +# +# "THE BEER-WARE LICENSE" (Revision 43~maze) +# +# wrote these files. As long as you retain this notice you +# can do whatever you want with this stuff. If we meet some day, and you think +# this stuff is worth it, you can buy me a beer in return. + +from __future__ import print_function + +import atexit +import math +import os +import random +import re +import sys +import time +from signal import signal, SIGPIPE, SIG_DFL + +PY3 = sys.version_info >= (3,) + +# override default handler so no exceptions on SIGPIPE +signal(SIGPIPE, SIG_DFL) + +# Reset terminal colors at exit +def reset(): + sys.stdout.write("\x1b[0m") + sys.stdout.flush() + + +atexit.register(reset) + + +STRIP_ANSI = re.compile(r"\x1b\[(\d+)(;\d+)?(;\d+)?[m|K]") +COLOR_ANSI = ( + (0x00, 0x00, 0x00), + (0xCD, 0x00, 0x00), + (0x00, 0xCD, 0x00), + (0xCD, 0xCD, 0x00), + (0x00, 0x00, 0xEE), + (0xCD, 0x00, 0xCD), + (0x00, 0xCD, 0xCD), + (0xE5, 0xE5, 0xE5), + (0x7F, 0x7F, 0x7F), + (0xFF, 0x00, 0x00), + (0x00, 0xFF, 0x00), + (0xFF, 0xFF, 0x00), + (0x5C, 0x5C, 0xFF), + (0xFF, 0x00, 0xFF), + (0x00, 0xFF, 0xFF), + (0xFF, 0xFF, 0xFF), +) + + +class stdoutWin: + def __init__(self): + self.output = sys.stdout + self.string = "" + self.i = 0 + + def isatty(self): + return self.output.isatty() + + def write(self, s): + self.string = self.string + s + + def flush(self): + return self.output.flush() + + def prints(self): + string = 'echo|set /p="%s"' % (self.string) + os.system(string) + self.i += 1 + self.string = "" + + def println(self): + print() + self.prints() + + +class LolCat(object): + def __init__(self, mode=256, output=sys.stdout): + self.mode = mode + self.output = output + + def _distance(self, rgb1, rgb2): + return sum(map(lambda c: (c[0] - c[1]) ** 2, zip(rgb1, rgb2))) + + def ansi(self, rgb): + r, g, b = rgb + + if self.mode in (8, 16): + colors = COLOR_ANSI[: self.mode] + matches = [ + (self._distance(c, map(int, rgb)), i) for i, c in enumerate(colors) + ] + matches.sort() + color = matches[0][1] + + return "3%d" % (color,) + else: + gray_possible = True + sep = 2.5 + + while gray_possible: + if r < sep or g < sep or b < sep: + gray = r < sep and g < sep and b < sep + gray_possible = False + + sep += 42.5 + + if gray: + color = 232 + int(float(sum(rgb) / 33.0)) + else: + color = sum( + [16] + + [ + int(6 * float(val) / 256) * mod + for val, mod in zip(rgb, [36, 6, 1]) + ] + ) + + return "38;5;%d" % (color,) + + def wrap(self, *codes): + return "\x1b[%sm" % ("".join(codes),) + + def rainbow(self, freq, i): + r = math.sin(freq * i) * 127 + 128 + g = math.sin(freq * i + 2 * math.pi / 3) * 127 + 128 + b = math.sin(freq * i + 4 * math.pi / 3) * 127 + 128 + return [r, g, b] + + def cat(self, fd, options): + if options.animate: + self.output.write("\x1b[?25l") + + for line in fd: + options.os += 1 + self.println(line, options) + + if options.animate: + self.output.write("\x1b[?25h") + + def println(self, s, options): + s = s.rstrip() + if options.force or self.output.isatty(): + s = STRIP_ANSI.sub("", s) + + if options.animate: + self.println_ani(s, options) + else: + self.println_plain(s, options) + + self.output.write("\n") + self.output.flush() + if os.name == "nt": + self.output.println() + + def println_ani(self, s, options): + if not s: + return + + for i in range(1, options.duration): + self.output.write("\x1b[%dD" % (len(s),)) + self.output.flush() + options.os += options.spread + self.println_plain(s, options) + time.sleep(1.0 / options.speed) + + def println_plain(self, s, options): + for i, c in enumerate(s if PY3 else s.decode(options.charset_py2, "replace")): + rgb = self.rainbow(options.freq, options.os + i / options.spread) + self.output.write( + "".join( + [ + self.wrap(self.ansi(rgb)), + c if PY3 else c.encode(options.charset_py2, "replace"), + ] + ) + ) + if os.name == "nt": + self.output.print() + + +def detect_mode(term_hint="xterm-256color"): + """ + Poor-mans color mode detection. + """ + if "ANSICON" in os.environ: + return 16 + elif os.environ.get("ConEmuANSI", "OFF") == "ON": + return 256 + else: + term = os.environ.get("TERM", term_hint) + if term.endswith("-256color") or term in ("xterm", "screen"): + return 256 + elif term.endswith("-color") or term in ("rxvt",): + return 16 + else: + return 256 # optimistic default + + +def run(): + """Main entry point.""" + import optparse + + parser = optparse.OptionParser(usage=r"%prog [] [file ...]") + parser.add_option( + "-p", "--spread", type="float", default=3.0, help="Rainbow spread" + ) + parser.add_option( + "-F", "--freq", type="float", default=0.1, help="Rainbow frequency" + ) + parser.add_option("-S", "--seed", type="int", default=0, help="Rainbow seed") + parser.add_option( + "-a", + "--animate", + action="store_true", + default=False, + help="Enable psychedelics", + ) + parser.add_option( + "-d", "--duration", type="int", default=12, help="Animation duration" + ) + parser.add_option( + "-s", "--speed", type="float", default=20.0, help="Animation speed" + ) + parser.add_option( + "-f", + "--force", + action="store_true", + default=False, + help="Force colour even when stdout is not a tty", + ) + + parser.add_option( + "-3", action="store_const", dest="mode", const=8, help="Force 3 bit colour mode" + ) + parser.add_option( + "-4", + action="store_const", + dest="mode", + const=16, + help="Force 4 bit colour mode", + ) + parser.add_option( + "-8", + action="store_const", + dest="mode", + const=256, + help="Force 8 bit colour mode", + ) + + parser.add_option( + "-c", + "--charset-py2", + default="utf-8", + help="Manually set a charset to convert from, for python 2.7", + ) + + options, args = parser.parse_args() + options.os = random.randint(0, 256) if options.seed == 0 else options.seed + options.mode = options.mode or detect_mode() + + if os.name == "nt": + lolcat = LolCat(mode=options.mode, output=stdoutWin()) + else: + lolcat = LolCat(mode=options.mode) + + if not args: + args = ["-"] + + for filename in args: + try: + if filename == "-": + lolcat.cat(sys.stdin, options) + else: + with open(filename, "r", errors="backslashreplace") as handle: + lolcat.cat(handle, options) + except IOError as error: + sys.stderr.write(str(error) + "\n") + except KeyboardInterrupt: + sys.stderr.write("\n") + # exit 130 for terminated-by-ctrl-c, from http://tldp.org/LDP/abs/html/exitcodes.html + return 130 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py deleted file mode 100644 index e67032e..0000000 --- a/ipdata/test_cli.py +++ /dev/null @@ -1,134 +0,0 @@ -import multiprocessing -import sys -import unittest -from io import StringIO -from unittest import TestCase -from unittest.mock import patch - -from .cli import json_filter, todo, _batch, get_json_value - -class CliTestCase(TestCase): - def test_json_filter(self): - json = {'a': {'b': 1, 'c': 2}, 'd': 3} - - res = json_filter(json, ('a.b',)) - self.assertDictEqual({'a': {'b': 1}}, res) - - res = json_filter(json, ('a',)) - self.assertDictEqual({'a': {'b': 1, 'c': 2}}, res) - - res = json_filter(json, ('a.c', 'd')) - self.assertDictEqual({'a': {'c': 2}, 'd': 3}, res) - - res = json_filter(json, ('d',)) - self.assertDictEqual({'d': 3}, res) - - -# class CliTodoTestCase(TestCase): -# @staticmethod -# def test_todo_call_with_ip_address(): -# with patch.object(sys, 'argv', ['__nope__', '1.1.1.1']), \ -# patch('.cli.ip') as m2, \ -# patch('.cli.cli') as m3: -# todo() -# m3.assert_not_called() -# m2.assert_called_once() - -# @staticmethod -# def test_todo_call_with_param(): -# with patch.object(sys, 'argv', ['__nope__', 'abc']), \ -# patch('.cli.ip') as m2, \ -# patch('.cli.cli') as m3: -# todo() -# m2.assert_not_called() -# m3.assert_called_once() - -# @staticmethod -# def test_todo_call_without_params(): -# with patch.object(sys, 'argv', ['__nope__']), \ -# patch('.cli.ip') as m2, \ -# patch('.cli.cli') as m3: -# todo() -# m2.assert_not_called() -# m3.assert_called_once() - - -# class CliInitTestCase(TestCase): -# @staticmethod -# def test_init_noargs(): -# with patch.object(sys, 'argv', ['__nope__', 'init']), \ -# patch('.cli.init') as m2, \ -# patch('sys.exit') as m3: -# todo() -# m2.assert_not_called() -# m3.assert_called_once_with(2) - - -class BatchTestCase(TestCase): - def setUp(self) -> None: - self.ip_list = StringIO('1.1.1.1\n8.8.8.8') - self.ip_list.name = 'in.txt' - self.output = StringIO() - self.output.name = 'out.json' - self.api_key = '123' - - def test_workers(self): - with patch('multiprocessing.Pool') as m: - _batch(self.ip_list, self.output, 'JSON', None, 0, self.api_key) - m.called_once_with(multiprocessing.cpu_count()) - - _batch(self.ip_list, self.output, 'JSON', None, multiprocessing.cpu_count() + 10, self.api_key) - m.called_once_with(multiprocessing.cpu_count()) - - _batch(self.ip_list, self.output, 'JSON', None, 1, self.api_key) - m.called_once_with(1) - - def test_json_filter(self): - json = {'a': 1, 'b': {'c': 2, 'd': 3}, 'e': [{'f': 4, 'g': 6}, {'f': 5, 'g': 7}]} - res = json_filter(json, ['a']) - expected = {'a': 1} - self.assertDictEqual(expected, res) - - res = json_filter(json, ['b.c', 'b.d']) - expected = {'b': {'c': 2, 'd': 3}} - self.assertDictEqual(expected, res) - - self.assertRaises(ValueError, lambda: json_filter(json, ['a.a'])) - - res = json_filter(json, ['a', 'b.c']) - expected = {'a': 1, 'b': {'c': 2}} - self.assertDictEqual(expected, res) - - res = json_filter(json, ['a', 'e.f']) - expected = {'a': 1, 'e': [4, 5]} - self.assertDictEqual(expected, res) - - self.assertRaises(ValueError, lambda: json_filter([ - {'a': 1, 'b': 1}, {'a': 2, 'b': 2}, {'a': 3, 'b': 3}], ['m.a', 'm.b'])) - - def test_get_json_value(self): - json = {'ip': 1, 'languages': ['English', 'Russian']} - res = get_json_value(json, 'ip') - self.assertEqual(1, res) - - res = get_json_value(json, 'languages.name') - self.assertEqual('English,Russian', res) - - json = {'a': 1, 'b': {'c': 2, 'd': None}, 'e': None, 'f': 123} - res = get_json_value(json, 'b.c') - self.assertEqual(2, res) - res = get_json_value(json, 'b.d') - self.assertEqual(None, res) - res = get_json_value(json, 'e.f') - self.assertIsNone(res) - self.assertRaises(ValueError, lambda: get_json_value(json, 'a.b')) - - json = {'a': None} - res = get_json_value(json, 'a') - self.assertEqual(None, res) - res = get_json_value(json, 'aa') - self.assertEqual(None, res) - - -if __name__ == '__main__': - unittest.main() diff --git a/ipdata/test_geofeeds.py b/ipdata/test_geofeeds.py new file mode 100644 index 0000000..d35a89a --- /dev/null +++ b/ipdata/test_geofeeds.py @@ -0,0 +1,20 @@ +import pytest + +from .geofeeds import Entry, GeofeedValidationError + + +@pytest.mark.parametrize( + "entry", + [ + "172.32.0.0/55,,,,", + "172.32.0.0/11,,,,", + "172.32.0.0/11,ZZ,,,", + "172.32.0.0/11,US,ZZ-ZZ,,", + "172.32.0.0/11,US,LU-LU,,", + ], +) +@pytest.mark.geofeed +def test_validation_rules(entry): + entry = Entry(*entry.split(",")) + with pytest.raises(GeofeedValidationError): + entry.validate() diff --git a/ipdata/test_ipdata.py b/ipdata/test_ipdata.py index cf8e804..a6dd63e 100644 --- a/ipdata/test_ipdata.py +++ b/ipdata/test_ipdata.py @@ -1,39 +1,69 @@ -from .ipdata import * +import ipaddress +import os + +import pytest from dotenv import load_dotenv +from hypothesis import given, settings +from hypothesis import strategies as st -import unittest -import os +from pathlib import Path +import ipdata + +# local testing +pwd = Path(__file__).parent.resolve() +load_dotenv(f"{pwd}/.env") + +# Github CI runs +IPDATA_API_KEY = os.environ.get("IPDATA_API_KEY") +ipdata.api_key = IPDATA_API_KEY + + +@given(st.ip_addresses(network="8.8.8.0/24")) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_lookup_v4(ip): + ip = str(ip) + data = ipdata.lookup(ip) + assert data.ip == ip + + +@given(st.ip_addresses(network="2620:11a:a000::/40")) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_lookup_v6(ip): + ip = str(ip) + data = ipdata.lookup(ip) + + # Capitalize to handle 2620:11A:A000::' == '2620:11a:a000::' + assert data.ip.upper() == ip.upper() -load_dotenv() -ipdata_api_key = os.environ.get("IPDATA_API_KEY") +@given( + st.lists(st.ip_addresses(network="2620:11a:a000::/40"), min_size=1, max_size=100) +) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_bulk_v6(ips): + ips = [str(ip) for ip in ips] + data = ipdata.bulk(ips) -class TestAPIMethods(unittest.TestCase): + # Check we got all results + assert len(data.responses) == len(ips) - def test_param_less(self): - ipdata = IPData(ipdata_api_key) - status_code = ipdata.lookup().get('status') - self.assertEqual(status_code, 200) + # Capitalize to handle 2620:11A:A000::' == '2620:11a:a000::' + for pair in zip(ips, [response.ip for response in data.responses]): + assert pair[0].upper() == pair[0].upper() - def test_param(self): - ipdata = IPData(ipdata_api_key) - status_code = ipdata.lookup('8.8.8.8').get('status') - self.assertEqual(status_code, 200) - - def test_select_field(self): - ipdata = IPData(ipdata_api_key) - response = ipdata.lookup('8.8.8.8', select_field='ip') - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) - def test_fields_param(self): - ipdata = IPData(ipdata_api_key) - response = ipdata.lookup('8.8.8.8',fields=['ip']) - self.assertEqual(response, {'ip': '8.8.8.8', 'status': 200}) +@given(st.lists(st.ip_addresses(network="8.8.8.0/24"), min_size=1, max_size=100)) +@settings(deadline=None, max_examples=100) +@pytest.mark.api +def test_bulk_v4(ips): + ips = [str(ip) for ip in ips] + data = ipdata.bulk(ips) -# def test_bulk_lookup(self): -# ipdata = ipdata('paid-key-here') -# response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1'],fields=['ip']) -# self.assertEqual(response, {'response': [{'ip': '8.8.8.8'}, {'ip': '1.1.1.1'}], 'status': 200}) + # Check we got all results + assert len(data.responses) == len(ips) -if __name__ == '__main__': - unittest.main() + for pair in zip(ips, [response.ip for response in data.responses]): + assert pair[0] == pair[0] diff --git a/pyproject.toml b/pyproject.toml index 07de284..40519b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,9 @@ [build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +markers = [ + "geofeed", + "api", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b818862..fdf7847 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ requests +rich click -tqdm -rich \ No newline at end of file +click_default_group +pyperclip +pytest +hypothesis +python-dotenv +mmh3 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index cff26a9..045a4ee 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,35 @@ -[bumpversion] -current_version = 3.4.6 - [metadata] -description-file = README.md \ No newline at end of file +name = ipdata +version = 4.0.0 +author = Jonathan Kosgei +author_email = jonathan@ipdata.co +description = This is the official IPData client library for Python +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/ipdata/python +project_urls = + Bug Tracker = https://github.com/ipdata/python/issues +classifiers = + Programming Language :: Python :: 3.9 + License :: OSI Approved :: MIT License + Operating System :: OS Independent + +[options] +package_dir = + = ipdata +python_requires = >=3.9 +install_requires = + requests + rich + click + click_default_group + pyperclip + pytest + hypothesis + python-dotenv + mmh3 + +[options.entry_points] +console_scripts = + ipdata = cli:cli + geofeed_validate = cli:validate \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 1d7867f..0000000 --- a/setup.py +++ /dev/null @@ -1,35 +0,0 @@ -import pathlib -from setuptools import setup - -# The directory containing this file -HERE = pathlib.Path(__file__).parent - -# The text of the README file -README = (HERE / "README.md").read_text() - -# This call to setup() does all the work -# ToDo: add bumpversion, run in ci -setup( - name="ipdata", - version="3.4.6", - description="Python Client for the ipdata IP Geolocation API", - long_description=README, - long_description_content_type="text/markdown", - url="https://github.com/ipdata/python", - author="Jonathan Kosgei", - author_email="jonatha@ipdata.co", - license="MIT", - classifiers=[ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - ], - packages=["ipdata"], - include_package_data=True, - install_requires=["requests", "click", "tqdm", "rich"], - entry_points={ - 'console_scripts': [ - 'ipdata = ipdata.cli:todo', - ] - }, -) From 2250a2e9c3ab4c812bdbddd2c7c14c09bc8e3225 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Wed, 18 May 2022 23:46:37 +0300 Subject: [PATCH 069/100] Fix relative import --- ipdata/geofeeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipdata/geofeeds.py b/ipdata/geofeeds.py index 38c7969..4280285 100644 --- a/ipdata/geofeeds.py +++ b/ipdata/geofeeds.py @@ -11,7 +11,7 @@ from pathlib import Path from rich.logging import RichHandler -from codes import COUNTRIES, REGION_CODES +from .codes import COUNTRIES, REGION_CODES FORMAT = "%(message)s" logging.basicConfig( From d97416da157d654379d9f2f5278b30e10b3cc865 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 00:01:33 +0300 Subject: [PATCH 070/100] remove mmh3 --- ipdata/geofeeds.py | 14 ++++++-------- requirements.txt | 3 +-- setup.cfg | 1 - 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ipdata/geofeeds.py b/ipdata/geofeeds.py index 4280285..50e1d84 100644 --- a/ipdata/geofeeds.py +++ b/ipdata/geofeeds.py @@ -1,14 +1,14 @@ """ Geofeeds classes used in the CLI for the validator. """ -import requests -import mmh3 import csv +import hashlib import ipaddress -import json import logging - +import random from pathlib import Path + +import requests from rich.logging import RichHandler from .codes import COUNTRIES, REGION_CODES @@ -49,10 +49,8 @@ def _download(self): :raises Exception: if a failure occurs when downloading the geofeed """ - url_hash = mmh3.hash( - self.source, 42, signed=False - ) # 42 is the seed so that we get the same hashes on different devices, signed is so that we don't get a negative number - cache_path = f"{pwd}/{self.dir}/{url_hash}.csv" + random_name = hashlib.sha224( str(random.getrandbits(256)).encode('utf-8') ).hexdigest()[:16] + cache_path = f"{pwd}/{self.dir}/{random_name}.csv" try: response = requests.get(self.source, timeout=60) diff --git a/requirements.txt b/requirements.txt index fdf7847..ae6143b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,4 @@ click_default_group pyperclip pytest hypothesis -python-dotenv -mmh3 \ No newline at end of file +python-dotenv \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 045a4ee..3b7d03f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,7 +27,6 @@ install_requires = pytest hypothesis python-dotenv - mmh3 [options.entry_points] console_scripts = From 1177ac8049ae0da5d3368dbc48ad02b1eee5ce68 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 00:12:55 +0300 Subject: [PATCH 071/100] fix import --- ipdata/cli.py | 2 +- ipdata/geofeeds.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index 4365833..abd6a11 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -88,7 +88,7 @@ def _lookup(ipdata, *args, **kwargs): try: response = ipdata.lookup(*args, **kwargs) except Exception as e: - log.error(f"Error during lookup: {e}") + log.error(e) else: return response diff --git a/ipdata/geofeeds.py b/ipdata/geofeeds.py index 50e1d84..40587b9 100644 --- a/ipdata/geofeeds.py +++ b/ipdata/geofeeds.py @@ -11,7 +11,7 @@ import requests from rich.logging import RichHandler -from .codes import COUNTRIES, REGION_CODES +from codes import COUNTRIES, REGION_CODES FORMAT = "%(message)s" logging.basicConfig( From 31454c26dda76cc0a4e0639cbe3ee4c7504d9a8c Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 00:41:16 +0300 Subject: [PATCH 072/100] Fixed all import issues --- ipdata/cli.py | 6 +++--- ipdata/geofeeds.py | 2 +- setup.cfg | 9 ++++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/ipdata/cli.py b/ipdata/cli.py index abd6a11..2453237 100644 --- a/ipdata/cli.py +++ b/ipdata/cli.py @@ -66,9 +66,9 @@ from rich.progress import Progress from rich.tree import Tree -from lolcat import LolCat -from geofeeds import Geofeed, GeofeedValidationError -from ipdata import DotDict, IPData +from .lolcat import LolCat +from .geofeeds import Geofeed, GeofeedValidationError +from .ipdata import DotDict, IPData console = Console() diff --git a/ipdata/geofeeds.py b/ipdata/geofeeds.py index 40587b9..50e1d84 100644 --- a/ipdata/geofeeds.py +++ b/ipdata/geofeeds.py @@ -11,7 +11,7 @@ import requests from rich.logging import RichHandler -from codes import COUNTRIES, REGION_CODES +from .codes import COUNTRIES, REGION_CODES FORMAT = "%(message)s" logging.basicConfig( diff --git a/setup.cfg b/setup.cfg index 3b7d03f..31b182c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,7 @@ classifiers = [options] package_dir = - = ipdata + = . python_requires = >=3.9 install_requires = requests @@ -30,5 +30,8 @@ install_requires = [options.entry_points] console_scripts = - ipdata = cli:cli - geofeed_validate = cli:validate \ No newline at end of file + ipdata = ipdata.cli:cli + geofeed_validate = ipdata.cli:validate + +[options.packages.find] +where = ipdata \ No newline at end of file From 59503a789b9e4bd8c18763a9e86696e395bbdada Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 00:55:51 +0300 Subject: [PATCH 073/100] testing imports fix --- setup.cfg | 5 +++-- {ipdata => src/ipdata}/__init__.py | 0 {ipdata => src/ipdata}/cli.py | 0 {ipdata => src/ipdata}/codes.py | 0 {ipdata => src/ipdata}/geofeeds.py | 0 {ipdata => src/ipdata}/ipdata.py | 0 {ipdata => src/ipdata}/lolcat.py | 0 {ipdata => src/ipdata}/test_geofeeds.py | 0 {ipdata => src/ipdata}/test_ipdata.py | 0 9 files changed, 3 insertions(+), 2 deletions(-) rename {ipdata => src/ipdata}/__init__.py (100%) rename {ipdata => src/ipdata}/cli.py (100%) rename {ipdata => src/ipdata}/codes.py (100%) rename {ipdata => src/ipdata}/geofeeds.py (100%) rename {ipdata => src/ipdata}/ipdata.py (100%) rename {ipdata => src/ipdata}/lolcat.py (100%) rename {ipdata => src/ipdata}/test_geofeeds.py (100%) rename {ipdata => src/ipdata}/test_ipdata.py (100%) diff --git a/setup.cfg b/setup.cfg index 31b182c..d91fe07 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,8 @@ classifiers = [options] package_dir = - = . + = src +packages = find: python_requires = >=3.9 install_requires = requests @@ -34,4 +35,4 @@ console_scripts = geofeed_validate = ipdata.cli:validate [options.packages.find] -where = ipdata \ No newline at end of file +where = src \ No newline at end of file diff --git a/ipdata/__init__.py b/src/ipdata/__init__.py similarity index 100% rename from ipdata/__init__.py rename to src/ipdata/__init__.py diff --git a/ipdata/cli.py b/src/ipdata/cli.py similarity index 100% rename from ipdata/cli.py rename to src/ipdata/cli.py diff --git a/ipdata/codes.py b/src/ipdata/codes.py similarity index 100% rename from ipdata/codes.py rename to src/ipdata/codes.py diff --git a/ipdata/geofeeds.py b/src/ipdata/geofeeds.py similarity index 100% rename from ipdata/geofeeds.py rename to src/ipdata/geofeeds.py diff --git a/ipdata/ipdata.py b/src/ipdata/ipdata.py similarity index 100% rename from ipdata/ipdata.py rename to src/ipdata/ipdata.py diff --git a/ipdata/lolcat.py b/src/ipdata/lolcat.py similarity index 100% rename from ipdata/lolcat.py rename to src/ipdata/lolcat.py diff --git a/ipdata/test_geofeeds.py b/src/ipdata/test_geofeeds.py similarity index 100% rename from ipdata/test_geofeeds.py rename to src/ipdata/test_geofeeds.py diff --git a/ipdata/test_ipdata.py b/src/ipdata/test_ipdata.py similarity index 100% rename from ipdata/test_ipdata.py rename to src/ipdata/test_ipdata.py From c90a4b4fb44709c223496bf086b58fff29e83e54 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 01:01:05 +0300 Subject: [PATCH 074/100] pushing imports fix --- src/ipdata/ipdata.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/ipdata/ipdata.py b/src/ipdata/ipdata.py index af837d3..540beed 100644 --- a/src/ipdata/ipdata.py +++ b/src/ipdata/ipdata.py @@ -21,6 +21,7 @@ import ipaddress import requests import logging +import urllib3 from requests.adapters import HTTPAdapter, Retry from rich.logging import RichHandler @@ -115,14 +116,21 @@ def __init__( if debug: self.log.setLevel(logging.DEBUG) + # Work around renamed argument in urllib3. + if hasattr(urllib3.util.Retry.DEFAULT, "allowed_methods"): + methods_arg = "allowed_methods" + else: + methods_arg = "method_whitelist" + # Retry settings - retries = Retry( - total=retry_limit, - backoff_factor=retry_backoff_factor, - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["POST", "GET"], - ) - adapter = HTTPAdapter(max_retries=retries) + retry_args = { + "total": retry_limit, + "backoff_factor": retry_backoff_factor, + "status_forcelist": [429, 500, 502, 503, 504], + methods_arg: {"POST"}, + } + + adapter = HTTPAdapter(max_retries=urllib3.Retry(**retry_args)) self._session = requests.Session() self._session.mount("http", adapter) From c360c993c26f5d18639a7ca8cec2903565ca30bf Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 01:33:42 +0300 Subject: [PATCH 075/100] slight tweaks to rich panels --- src/ipdata/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index 2453237..b438b91 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -131,7 +131,7 @@ def pretty_print_data(data): # generate panels! for key, value in data.items(): # simple case - if type(value) is str: + if type(value) in [str, bool]: single_value_panels.append( Panel(f"[b]{key}[/b]\n[yellow]{value}", expand=True) ) @@ -140,7 +140,13 @@ def pretty_print_data(data): if type(value) is dict: tree = Tree(key) for k, v in value.items(): - sub_tree = tree.add(f"[b]{k}[/b]\n[yellow]{v}") + if key == "threat": + if v: + sub_tree = tree.add(f"[b]{k}[/b]\n[bright_red]{v}") + else: + sub_tree = tree.add(f"[b]{k}[/b]\n[green]{v}") + else: + sub_tree = tree.add(f"[b]{k}[/b]\n[yellow]{v}") multiple_value_panels.append(Panel(tree, expand=False)) # if value if a list we generate nested trees From 7be87934aa3d56a22db427336c94a8e94376ed54 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 01:51:53 +0300 Subject: [PATCH 076/100] Added `--exclude/-e` flag to allow excluding fields --- src/ipdata/cli.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index b438b91..7babce0 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -248,8 +248,11 @@ def usage(ctx, api_key): @cli.command(default=True) @click.argument("resource", required=False, type=str, default="") @click.option( - "--fields", "-f", required=False, multiple=True -) # TODO: add list of supported fields + "--fields", "-f", required=False, multiple=True, default=[], +) +@click.option( + "--exclude", "-e", required=False, multiple=True, default=[], +) @click.option("--api-key", "-k", required=False, default=None, help="ipdata API Key") @click.option( "--pretty-print", @@ -276,7 +279,7 @@ def usage(ctx, api_key): help="Copy the result to the clipboard", ) @click.pass_context -def lookup(ctx, resource, fields, api_key, pretty_print, raw, copy): +def lookup(ctx, resource, fields, api_key, pretty_print, raw, copy, exclude): """ Lookup resources by using the IPData class methods. @@ -290,6 +293,14 @@ def lookup(ctx, resource, fields, api_key, pretty_print, raw, copy): api_key = api_key if api_key else ctx.obj["api-key"] ipdata = IPData(api_key) + # enforce mutual exclusivity of fields and exclude + if exclude and fields: + raise click.ClickException("'--fields / -f' and '--exclude / -e' are mutually exclusive.") + + # if the user wants to exclude some fields, get all the fields in fields that are not in exclude + if exclude: + fields = set(ipdata.valid_fields).difference(set(exclude)) + with console.status( f"""Looking up {resource if resource else "this device's IP address"}""", spinner="dots12", From 49c875f302bbe789d82022eaf31e870e046a865d Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 01:54:58 +0300 Subject: [PATCH 077/100] Added `--exclude/-e` to bulk --- src/ipdata/cli.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index 7babce0..35e1ca0 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -344,7 +344,10 @@ def process(resources, processor, fields): @click.argument("input", required=True, type=click.File(mode="r", encoding="utf-8")) @click.option( "--fields", "-f", required=False, multiple=True -) # TODO: add list of supported fields +) +@click.option( + "--exclude", "-e", required=False, multiple=True, default=[], +) @click.option( "--output", "-o", required=True, type=click.File(mode="w", encoding="utf-8") ) @@ -356,7 +359,7 @@ def process(resources, processor, fields): help="Format of output", ) @click.pass_context -def batch(ctx, input, fields, output, format): +def batch(ctx, input, fields, output, format, exclude): """ Batch command for doing fast bulk processing. @@ -373,6 +376,15 @@ def batch(ctx, input, fields, output, format): ) return + # enforce mutual exclusivity of fields and exclude + if exclude and fields: + raise click.ClickException("'--fields / -f' and '--exclude / -e' are mutually exclusive.") + + # if the user wants to exclude some fields, get all the fields in fields that are not in exclude + if exclude: + fields = set(ipdata.valid_fields).difference(set(exclude)) + + # Prepare requests ipdata = IPData(ctx.obj["api-key"]) resources = [resource.strip() for resource in input.readlines()] From 71487b0f3ec71bdcc6e5c3d4a1260d7efe8a048b Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 02:08:09 +0300 Subject: [PATCH 078/100] updated help comment --- src/ipdata/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index 35e1ca0..376df07 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -356,7 +356,7 @@ def process(resources, processor, fields): required=False, type=click.Choice(("JSON", "CSV"), case_sensitive=False), default="JSON", - help="Format of output", + help="File format for output", ) @click.pass_context def batch(ctx, input, fields, output, format, exclude): From 2970ca50cddb284061cf0d8bf638a9bb5a736fae Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 02:12:50 +0300 Subject: [PATCH 079/100] update help comment --- src/ipdata/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index 376df07..af95c35 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -356,7 +356,7 @@ def process(resources, processor, fields): required=False, type=click.Choice(("JSON", "CSV"), case_sensitive=False), default="JSON", - help="File format for output", + help="Output file format", ) @click.pass_context def batch(ctx, input, fields, output, format, exclude): From a78730657158219730d76c9ff29bc3707d9c5533 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 02:33:44 +0300 Subject: [PATCH 080/100] update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f8a08f1..2506d72 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __pycache__/ .env *.egg-info/ *.egg -*.pyc \ No newline at end of file +*.pyc +.hypothesis/ \ No newline at end of file From b9613a1ca0a0e2fbdf48477f4c1f32a9ad183d50 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Thu, 19 May 2022 02:37:42 +0300 Subject: [PATCH 081/100] bump version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index d91fe07..e927646 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.0 +version = 4.0.2 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python From 90990a6d0b777452289f02da11077c2d26f9d516 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 18:55:03 +0300 Subject: [PATCH 082/100] improve geofeed validation and add lru_cache to API calling functions --- setup.cfg | 1 - src/ipdata/cli.py | 31 ++++++++++++++++++++++--------- src/ipdata/geofeeds.py | 15 +++++++++------ src/ipdata/ipdata.py | 5 +++++ 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/setup.cfg b/setup.cfg index e927646..ebfe666 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,6 @@ install_requires = [options.entry_points] console_scripts = ipdata = ipdata.cli:cli - geofeed_validate = ipdata.cli:validate [options.packages.find] where = src \ No newline at end of file diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index af95c35..810c6b8 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -248,10 +248,18 @@ def usage(ctx, api_key): @cli.command(default=True) @click.argument("resource", required=False, type=str, default="") @click.option( - "--fields", "-f", required=False, multiple=True, default=[], + "--fields", + "-f", + required=False, + multiple=True, + default=[], ) @click.option( - "--exclude", "-e", required=False, multiple=True, default=[], + "--exclude", + "-e", + required=False, + multiple=True, + default=[], ) @click.option("--api-key", "-k", required=False, default=None, help="ipdata API Key") @click.option( @@ -295,7 +303,9 @@ def lookup(ctx, resource, fields, api_key, pretty_print, raw, copy, exclude): # enforce mutual exclusivity of fields and exclude if exclude and fields: - raise click.ClickException("'--fields / -f' and '--exclude / -e' are mutually exclusive.") + raise click.ClickException( + "'--fields / -f' and '--exclude / -e' are mutually exclusive." + ) # if the user wants to exclude some fields, get all the fields in fields that are not in exclude if exclude: @@ -342,11 +352,13 @@ def process(resources, processor, fields): @cli.command() @click.argument("input", required=True, type=click.File(mode="r", encoding="utf-8")) +@click.option("--fields", "-f", required=False, multiple=True) @click.option( - "--fields", "-f", required=False, multiple=True -) -@click.option( - "--exclude", "-e", required=False, multiple=True, default=[], + "--exclude", + "-e", + required=False, + multiple=True, + default=[], ) @click.option( "--output", "-o", required=True, type=click.File(mode="w", encoding="utf-8") @@ -378,13 +390,14 @@ def batch(ctx, input, fields, output, format, exclude): # enforce mutual exclusivity of fields and exclude if exclude and fields: - raise click.ClickException("'--fields / -f' and '--exclude / -e' are mutually exclusive.") + raise click.ClickException( + "'--fields / -f' and '--exclude / -e' are mutually exclusive." + ) # if the user wants to exclude some fields, get all the fields in fields that are not in exclude if exclude: fields = set(ipdata.valid_fields).difference(set(exclude)) - # Prepare requests ipdata = IPData(ctx.obj["api-key"]) resources = [resource.strip() for resource in input.readlines()] diff --git a/src/ipdata/geofeeds.py b/src/ipdata/geofeeds.py index 50e1d84..40f98f2 100644 --- a/src/ipdata/geofeeds.py +++ b/src/ipdata/geofeeds.py @@ -50,7 +50,7 @@ def _download(self): :raises Exception: if a failure occurs when downloading the geofeed """ random_name = hashlib.sha224( str(random.getrandbits(256)).encode('utf-8') ).hexdigest()[:16] - cache_path = f"{pwd}/{self.dir}/{random_name}.csv" + cache_path = f"{self.dir}/{random_name}.csv" try: response = requests.get(self.source, timeout=60) @@ -82,11 +82,10 @@ def entries(self): if "://" in self.source and not self.cache_path: self._download() path = self.cache_path - - # if path still contains a url it means the download was not successful in which case we yield nothing! - if "://" in path: - log.warning(f"No entries found {self.source}") - yield + # if path still contains a url it means the download was not successful in which case we yield nothing! + if not path: + log.warning(f"No entries found {self.source}") + return with open(path) as f: reader = csv.reader(f) @@ -99,6 +98,7 @@ def entries(self): yield GeofeedValidationError( f"[{entry}] is not a valid geofeed entry. The 'IP Range' and 'Country' fields are required however you must have the requisite minimum number of commas (currently 4) present" ) + return # instantiate an Entry object geofeed_entry = Entry(*entry) @@ -108,12 +108,15 @@ def entries(self): yield GeofeedValidationError( f"Duplicate prefixes found {geofeed_entry.ip_range}" ) + return self.prefixes.add(geofeed_entry.ip_range) # generate entry yield geofeed_entry + def __iter__(self): + yield from self.entries() class Entry(object): """ diff --git a/src/ipdata/ipdata.py b/src/ipdata/ipdata.py index 540beed..b32cfa3 100644 --- a/src/ipdata/ipdata.py +++ b/src/ipdata/ipdata.py @@ -22,6 +22,7 @@ import requests import logging import urllib3 +import functools from requests.adapters import HTTPAdapter, Retry from rich.logging import RichHandler @@ -135,6 +136,7 @@ def __init__( self._session = requests.Session() self._session.mount("http", adapter) + @functools.lru_cache(maxsize=100) def _validate_fields(self, fields): """ Validates the fields passed in by the user, first ensuring it's a collection. In prior versions 'fields' was a string, however it now needs to be a collection. @@ -152,6 +154,7 @@ def _validate_fields(self, fields): f"The field(s) {diff} are not supported. Only {self.valid_fields} are supported." ) + @functools.lru_cache(maxsize=100) def _validate_ip_address(self, ip): """ Checks that 'ip' is a valid IP Address. @@ -163,6 +166,7 @@ def _validate_ip_address(self, ip): if request_ip.is_private or request_ip.is_reserved or request_ip.is_multicast: raise ValueError(f"{ip} is a reserved IP Address") + @functools.lru_cache(maxsize=100) def lookup(self, resource="", fields=[]): """ Makes a GET request to the IPData API for the specified 'resource' and the given 'fields'. @@ -212,6 +216,7 @@ def lookup(self, resource="", fields=[]): return data + @functools.lru_cache(maxsize=100) def bulk(self, resources, fields=[]): """ Lookup up to 100 resources in one request. Makes a POST request wth the resources as a JSON array and the specified fields. From 64585aac9ef3d06fc17829620548ba9d99b4cca1 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 19:05:12 +0300 Subject: [PATCH 083/100] fix lookup function when using _proxy object --- README.md | 18 +++++++----------- src/ipdata/__init__.py | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e82861b..88adf0d 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,9 @@ Replace `test` with your API Key in the following examples. You can look up the calling IP address, that is, the IP address of the computer you are running this on by not passing an IP address to the `lookup` method. ``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup() -pprint(response) +>>> import ipdata +>>> ipdata.api_key = +>>> ipdata.lookup() ``` ### Looking up any IP Address @@ -48,11 +45,10 @@ pprint(response) You can look up any valid IPv4 or IPv6 address by passing it to the `lookup` method. ``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup('69.78.70.144') +>>> from rich import pprint +>>> import ipdata +>>> ipdata.api_key = +>>> response = ipdata.lookup('69.78.70.144') pprint(response) ``` diff --git a/src/ipdata/__init__.py b/src/ipdata/__init__.py index ff8df3f..f369529 100644 --- a/src/ipdata/__init__.py +++ b/src/ipdata/__init__.py @@ -14,7 +14,7 @@ default_client = None -def lookup(resource, fields=[]): +def lookup(resource="", fields=[]): return _proxy("lookup", resource=resource, fields=fields) From 0ab775190c5a2016e536f8bed721b6e8886acb08 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 19:11:13 +0300 Subject: [PATCH 084/100] check the 'IPDATA_API_KEY' environment variable for the API key if set --- src/ipdata/ipdata.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ipdata/ipdata.py b/src/ipdata/ipdata.py index b32cfa3..b5ba7c1 100644 --- a/src/ipdata/ipdata.py +++ b/src/ipdata/ipdata.py @@ -18,6 +18,7 @@ :class:`~.IPData` is the primary class for making API requests. """ +import os import ipaddress import requests import logging @@ -99,13 +100,15 @@ class IPData(object): def __init__( self, - api_key, + api_key=os.environ.get("IPDATA_API_KEY"), endpoint="https://api.ipdata.co/", timeout=60, retry_limit=7, retry_backoff_factor=1, debug=False, ): + if not api_key: + raise IPDataException("API Key not set. Set an API key via the 'IPDATA_API_KEY' environment variable or see the docs for other ways to do so.") # Request settings self.api_key = api_key self.endpoint = endpoint.rstrip("/") # remove trailing / From f1a3d3f321e3baf7f6956d4f1527e995d175c756 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 20:36:18 +0300 Subject: [PATCH 085/100] updated documentation --- README.md | 577 +++++++++++++++++++++++++---------------- src/ipdata/cli.py | 4 +- src/ipdata/geofeeds.py | 2 +- 3 files changed, 363 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 88adf0d..d298409 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutoria Install the latest version of the cli with `pip`. -``` +```bash pip install ipdata ``` or `easy_install` -``` +```bash easy_install ipdata ``` @@ -34,9 +34,9 @@ Replace `test` with your API Key in the following examples. You can look up the calling IP address, that is, the IP address of the computer you are running this on by not passing an IP address to the `lookup` method. -``` +```python >>> import ipdata ->>> ipdata.api_key = +>>> ipdata.api_key = "" >>> ipdata.lookup() ``` @@ -44,59 +44,88 @@ You can look up the calling IP address, that is, the IP address of the computer You can look up any valid IPv4 or IPv6 address by passing it to the `lookup` method. -``` +```python >>> from rich import pprint >>> import ipdata ->>> ipdata.api_key = +>>> ipdata.api_key = "" >>> response = ipdata.lookup('69.78.70.144') pprint(response) ```
Sample Response -``` -{'asn': {'asn': 'AS6167', - 'domain': 'verizonwireless.com', - 'name': 'Cellco Partnership DBA Verizon Wireless', - 'route': '69.78.0.0/16', - 'type': 'business'}, - 'calling_code': '1', - 'carrier': {'mcc': '310', 'mnc': '004', 'name': 'Verizon'}, - 'city': None, - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1527', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '69.78.70.144', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 37.751, - 'longitude': -97.822, - 'postal': None, - 'region': None, - 'region_code': None, - 'status': 200, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CST', - 'current_time': '2020-11-08T09:31:10.629425-06:00', - 'is_dst': False, - 'name': 'America/Chicago', - 'offset': '-0600'}} +```json +{ + "ip": "69.78.70.144", + "is_eu": false, + "city": null, + "region": null, + "region_code": null, + "country_name": "United States", + "country_code": "US", + "continent_name": "North America", + "continent_code": "NA", + "latitude": 37.750999450683594, + "longitude": -97.8219985961914, + "postal": null, + "calling_code": "1", + "flag": "https://ipdata.co/flags/us.png", + "emoji_flag": "\ud83c\uddfa\ud83c\uddf8", + "emoji_unicode": "U+1F1FA U+1F1F8", + "asn": { + "asn": "AS6167", + "name": "Verizon Business", + "domain": "verizon.com", + "route": "69.78.0.0/17", + "type": "business" + }, + "company": { + "name": "Verizon Business", + "domain": "verizon.com", + "network": "69.78.0.0/17", + "type": "business" + }, + "carrier": { + "name": "Verizon", + "mcc": "310", + "mnc": "004" + }, + "languages": [ + { + "name": "English", + "native": "English", + "code": "en" + } + ], + "currency": { + "name": "US Dollar", + "code": "USD", + "symbol": "$", + "native": "$", + "plural": "US dollars" + }, + "time_zone": { + "name": null, + "abbr": null, + "offset": null, + "is_dst": null, + "current_time": null + }, + "threat": { + "is_tor": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [] + }, + "count": "3895", + "status": 200 +} ```
@@ -105,50 +134,52 @@ pprint(response) If you only need a single data attribute about an IP address you can extract it by passing a `select_field` parameter to the `lookup` method. -``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup('8.8.8.8', select_field='asn') -pprint(response) +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.lookup('8.8.8.8', select_field='asn') ``` Response -``` -{'asn': {'asn': 'AS15169', - 'domain': 'google.com', - 'name': 'Google LLC', - 'route': '8.8.8.0/24', - 'type': 'hosting'}, - 'status': 200 +```json +{ + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "business" + }, + "status": 200 +} ``` ### Getting a number of specific fields If instead you need to get multiple specific fields you can pass a list of valid field names in a `fields` parameter. -``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.lookup('8.8.8.8',fields=['ip','asn','country_name']) -pprint(response) +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.lookup('8.8.8.8',fields=['ip','asn','country_name']) ``` Response -``` -{'asn': {'asn': 'AS15169', - 'domain': 'google.com', - 'name': 'Google LLC', - 'route': '8.8.8.0/24', - 'type': 'hosting'}, - 'country_name': 'United States', - 'ip': '8.8.8.8', - 'status': 200} +```json +{ + "ip": "8.8.8.8", + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "business" + }, + "country_name": "United States", + "status": 200 +} ``` ### Bulk Lookups @@ -157,99 +188,144 @@ The API provides a `/bulk` endpoint that allows you to look up upto 100 IP addre NOTE: Alternatively it is much simpler to process bulk lookups using the `ipdata` cli's `batch` command. All you need is a csv file with a list of IP addresses and you can get your results as either a JSON file or a CSV file! See the [CLI Bulk Lookup Documentation](https://docs.ipdata.co/command-line-interface/bulk-lookups-recommended) for details. -``` -from ipdata import ipdata -from pprint import pprint -# Create an instance of an ipdata object. Replace `test` with your API Key -ipdata = ipdata.IPData('test') -response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1']) -pprint(response) +```python +>>> import ipdata +>>> ipdata.api_key = "" +>>> ipdata.bulk(['8.8.8.8','1.1.1.1']) ```
Sample Response ``` -{'responses': [{'asn': {'asn': 'AS15169', - 'domain': 'google.com', - 'name': 'Google LLC', - 'route': '8.8.8.0/24', - 'type': 'hosting'}, - 'calling_code': '1', - 'city': None, - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1527', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': '🇺🇸', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '8.8.8.8', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 37.751, - 'longitude': -97.822, - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CST', - 'current_time': '2020-11-08T09:34:45.362725-06:00', - 'is_dst': False, - 'name': 'America/Chicago', - 'offset': '-0600'}}, - {'asn': {'asn': 'AS13335', - 'domain': 'cloudflare.com', - 'name': 'Cloudflare, Inc.', - 'route': '1.1.1.0/24', - 'type': 'hosting'}, - 'calling_code': '61', - 'city': None, - 'continent_code': 'OC', - 'continent_name': 'Oceania', - 'count': '1527', - 'country_code': 'AU', - 'country_name': 'Australia', - 'currency': {'code': 'AUD', - 'name': 'Australian Dollar', - 'native': '$', - 'plural': 'Australian dollars', - 'symbol': 'AU$'}, - 'emoji_flag': '🇦🇺', - 'emoji_unicode': 'U+1F1E6 U+1F1FA', - 'flag': 'https://ipdata.co/flags/au.png', - 'ip': '1.1.1.1', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': -33.494, - 'longitude': 143.2104, - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'AEDT', - 'current_time': '2020-11-09T02:34:45.364564+11:00', - 'is_dst': True, - 'name': 'Australia/Sydney', - 'offset': '+1100'}}], - 'status': 200} +{ + "responses": [ + { + "ip": "8.8.8.8", + "is_eu": false, + "city": null, + "region": null, + "region_code": null, + "country_name": "United States", + "country_code": "US", + "continent_name": "North America", + "continent_code": "NA", + "latitude": 37.750999450683594, + "longitude": -97.8219985961914, + "postal": null, + "calling_code": "1", + "flag": "https://ipdata.co/flags/us.png", + "emoji_flag": "\ud83c\uddfa\ud83c\uddf8", + "emoji_unicode": "U+1F1FA U+1F1F8", + "asn": { + "asn": "AS15169", + "name": "Google LLC", + "domain": "about.google", + "route": "8.8.8.0/24", + "type": "business" + }, + "company": { + "name": "Google LLC", + "domain": "google.com", + "network": "8.8.8.0/24", + "type": "business" + }, + "languages": [ + { + "name": "English", + "native": "English", + "code": "en" + } + ], + "currency": { + "name": "US Dollar", + "code": "USD", + "symbol": "$", + "native": "$", + "plural": "US dollars" + }, + "time_zone": { + "name": null, + "abbr": null, + "offset": null, + "is_dst": null, + "current_time": null + }, + "threat": { + "is_tor": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [] + }, + "count": "3931" + }, + { + "ip": "1.1.1.1", + "is_eu": null, + "city": null, + "region": null, + "region_code": null, + "country_name": null, + "country_code": null, + "continent_name": null, + "continent_code": null, + "latitude": null, + "longitude": null, + "postal": null, + "calling_code": null, + "flag": null, + "emoji_flag": null, + "emoji_unicode": null, + "asn": { + "asn": "AS13335", + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "route": "1.1.1.0/24", + "type": "business" + }, + "company": { + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "network": "1.1.1.0/24", + "type": "business" + }, + "languages": null, + "currency": { + "name": null, + "code": null, + "symbol": null, + "native": null, + "plural": null + }, + "time_zone": { + "name": null, + "abbr": null, + "offset": null, + "is_dst": null, + "current_time": null + }, + "threat": { + "is_tor": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [] + }, + "count": "3931" + } + ], + "status": 200 +} ```
@@ -258,7 +334,7 @@ pprint(response) ### Windows Installation Notes -IPData CLI needs [Python 3.8+](https://www.python.org/downloads/windows/). Python Windows installation program +IPData CLI needs [Python 3.9+](https://www.python.org/downloads/windows/). Python Windows installation program provides PIP so you can install IPData CLI the same way: ``` pip install ipdata @@ -266,96 +342,159 @@ pip install ipdata ### Available commands -``` -ipdata --help +```shell +➜ ipdata --help Usage: ipdata [OPTIONS] COMMAND [ARGS]... - CLI for ipdata API + Welcome to the ipdata CLI Options: - --api-key TEXT ipdata API Key + --api-key TEXT Your ipdata API Key. Get one for free from + https://ipdata.co/sign-up.html --help Show this message and exit. Commands: - batch - info - init - me - parse + lookup* Lookup resources by using the IPData class methods. + batch Batch command for doing fast bulk processing. + init Initialize the CLI by setting an API key. + usage Get today's usage. + validate Validates a geofeed file. ``` ### Initialize the cli with your API Key You need a valid API key from ipdata to use the cli. You can get a free key by [Signing up here](https://ipdata.co/sign-up.html). -``` -ipdata init +```shell +➜ ipdata init + _ _ _ +(_)_ __ __| | __ _| |_ __ _ +| | '_ \ / _` |/ _` | __/ _` | +| | |_) | (_| | (_| | || (_| | +|_| .__/ \__,_|\__,_|\__\__,_| + |_| + +✨ Successfully initialized. ``` You can also pass the `--api-key ` parameter to any command to specify a different API Key. ### Look up your own IP address -Running the `ipdata` command without any parameters will look up the IP address of the computer you are running the command on. Alternatively you can explicitly look up your own IP address by running `ipdata me` . You can filter the JSON response with `jq` to get any specific fields you might be interested in. +Running the `ipdata` command without any parameters will look up the IP address of the computer you are running the command on. Alternatively you can explicitly look up your own IP address by running `ipdata me`. -``` -ipdata -``` - -or +```shell +➜ ipdata +``` + +To pretty print the result pass the `-p` flag + +``` +╭────────────────────────────────╮ ╭────────────╮ ╭─────────────────╮ ╭──────────╮ ╭─────────────╮ ╭───────────────╮ ╭──────────────╮ ╭────────────────╮ ╭────────────────╮ ╭────────╮ ╭──────────────╮ +│ ip │ │ is_eu │ │ city │ │ region │ │ region_code │ │ country_name │ │ country_code │ │ continent_name │ │ continent_code │ │ postal │ │ calling_code │ +│ 24.24.24.24 │ │ False │ │ Syracuse │ │ New York │ │ NY │ │ United States │ │ US │ │ North America │ │ NA │ │ 13261 │ │ 1 │ +╰────────────────────────────────╯ ╰────────────╯ ╰─────────────────╯ ╰──────────╯ ╰─────────────╯ ╰───────────────╯ ╰──────────────╯ ╰────────────────╯ ╰────────────────╯ ╰────────╯ ╰──────────────╯ +╭────────────────────────────────╮ ╭────────────╮ ╭─────────────────╮ ╭──────────╮ +│ flag │ │ emoji_flag │ │ emoji_unicode │ │ count │ +│ https://ipdata.co/flags/us.png │ │ 🇺🇸 │ │ U+1F1FA U+1F1F8 │ │ 4086 │ +╰────────────────────────────────╯ ╰────────────╯ ╰─────────────────╯ ╰──────────╯ +╭────────────────────────────────╮ ╭──────────────────╮ ╭─────────────────╮ ╭────────────────╮ ╭───────────────────────────────╮ ╭───────────────────────╮ +│ asn │ │ company │ │ languages │ │ currency │ │ time_zone │ │ threat │ +│ ├── asn │ │ ├── name │ │ └── │ │ ├── name │ │ ├── name │ │ ├── is_tor │ +│ │ AS11351 │ │ │ Rr-Route │ │ ├── name │ │ │ US Dollar │ │ │ America/New_York │ │ │ False │ +│ ├── name │ │ ├── domain │ │ │ English │ │ ├── code │ │ ├── abbr │ │ ├── is_icloud_relay │ +│ │ Charter Communications Inc │ │ │ │ │ ├── native │ │ │ USD │ │ │ EDT │ │ │ False │ +│ ├── domain │ │ ├── network │ │ │ English │ │ ├── symbol │ │ ├── offset │ │ ├── is_proxy │ +│ │ spectrum.com │ │ │ 24.24.0.0/19 │ │ └── code │ │ │ $ │ │ │ -0400 │ │ │ False │ +│ ├── route │ │ └── type │ │ en │ │ ├── native │ │ ├── is_dst │ │ ├── is_datacenter │ +│ │ 24.24.0.0/18 │ │ business │ ╰─────────────────╯ │ │ $ │ │ │ True │ │ │ False │ +│ └── type │ ╰──────────────────╯ │ └── plural │ │ └── current_time │ │ ├── is_anonymous │ +│ business │ │ US dollars │ │ 2022-07-15T16:59:44-04:00 │ │ │ False │ +╰────────────────────────────────╯ ╰────────────────╯ ╰───────────────────────────────╯ │ ├── is_known_attacker │ + │ │ False │ + │ ├── is_known_abuser │ + │ │ False │ + │ ├── is_threat │ + │ │ False │ + │ ├── is_bogon │ + │ │ False │ + │ └── blocklists │ + │ [] │ + ╰───────────────────────╯ +``` + +### Look up any IP address + +You can pass any valid IPv4 or IPv6 address to the `ipdata` command to look it up. In case an invalid value is passed you will get the error `ERROR 'BLEH' does not appear to be an IPv4 or IPv6 address"`. -``` -ipdata me +```shell +➜ ipdata 8.8.8.8 ``` -Using `jq` to filter the responses +### Copying results to clipboard + +Use `-c` to copy the results to the clipboard! ``` -ipdata me | jq .country_name +➜ ipdata 1.1.1.1 -f ip -f asn -c +📋️ Copied result to clipboard! ``` -### Look up an arbitrary IP address +### Filtering results by a list of fields -You can pass any valid IPv4 or IPv6 address to the `ipdata` command to look it up. In case an invalid value is passed you will get the error `Error: No such command "1..@1....1..1"`. +Use `--fields` to filter the responses +```shell +➜ ipdata --fields city --fields country_name' ``` -ipdata 8.8.8.8 -``` - -### Filter results by specifying comma separated list of fields -In case you don't want to use `jq` to filter responses to get specific fields you can instead pass a fields argument to the `ipdata` command along with a comma separated list of valid fields. Invalid fields are ignored. It is important to not include any whitespace in the list. - -To access fields within nested objects eg. in the case of the `asn`, `languages`, `currency`, `time_zone` and `threat` objects, you can get a nested field by using dot notation with the name of the object and the name of the field. For example to get the time_zone name you would use `time_zone.name`, to get the time_zone abbreviation you would use `time_zone.abbr` +or use `-f` +```shell +➜ ipdata 1.1.1.1 -f ip -f asn ``` -ipdata 8.8.8.8 --fields ip,country_code + +```json +{ + "ip": "1.1.1.1", + "asn": { + "asn": "AS13335", + "name": "Cloudflare, Inc.", + "domain": "cloudflare.com", + "route": "1.1.1.0/24", + "type": "business" + }, + "status": 200 +} ``` ### Batch lookup -Perhaps the most useful command provided by the CLI is the ability to process a csv file with a list of IP addresses and write the results to file as either CSV or JSON! It could be a list of tens of thousands to millions of IP addresses and it will all be processed and the results filtered to your liking! +Perhaps the most useful command provided by the CLI is the ability to process a csv file with a list of IP addresses and write the results to file as either CSV or JSONL/NDJSON! It could be a list of tens of thousands to millions of IP addresses and it will all be processed and the results filtered to your liking! + When you use the JSON output format, the results are written to the output file you provide with one result per line. Each line being a valid and full JSON response object. If you only need a few fields eg. only the country name you can specify a field argument with the names of the fields you want, if you combine this with the CSV output format you will get very clean results with only the data you need! ### To get full JSON responses for further processing +This is the default output format, so you could omit the `--format JSON` + ``` -ipdata batch my_ip_backlog.csv --output geolocation_results.json +ipdata batch my_ip_backlog.csv --output geolocation_results.json --format JSON ``` ### Batch lookup with output to CSV file ``` -ipdata batch my_ip_backlog.csv --output results.csv --output-format CSV --fields ip,country_code +ipdata batch my_ip_backlog.csv --output results.csv --output-format CSV --fields ip --fields country_code ``` -The `--fields` option is required in case of CSV output. +The `--fields` option is required to use CSV output. #### Example Results -``` +```csv # ip,country_code 107.175.75.83,US 35.155.95.229,US @@ -369,29 +508,33 @@ The `--fields` option is required in case of CSV output. A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) +## Errors -### Parse +A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) -The `parse` command is for filtering GZipped JSON output of IPData from one or many files: -```shell -ipdata parse 2021-02-02.json.gz 2021-02-03.json.gz -``` -Fields filtering acts the same as in `batch` command: `--fields ip,country_code`. -By default, the command outputs to stdout. There is an option `--output ` to save filtered data to the file. +## Tests +To run all tests -## Errors +```shell +pytest +``` -A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) +## Geofeed tools +Geofeed publishers can use the `ipdata validate` command to validate their geofeeds before submission to ipdata. This will catch most but not all issues that might cause processing your geofeed to fail. +The validation closely follows https://datatracker.ietf.org/doc/html/draft-google-self-published-geofeeds-02 with one caveat, we expect the country field to be provided at the minimum. +You can provide either a url or a path to a local file. -## Tests +```shell +➜ ipdata validate https://example.com/geofeed.txt +``` -To run all tests +or -``` -python -m unittest +```shell +➜ ipdata validate geofeed.txt ``` \ No newline at end of file diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index 810c6b8..a0e6c5c 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -23,7 +23,7 @@ $ ipdata Your IP - $ ipdata 1.1.1.1 -f ip -f country_name + $ ipdata 1.1.1.1 -f ip -f asn { "ip": "1.1.1.1", "asn": { @@ -489,7 +489,7 @@ def validate(feed): """ geofeed = Geofeed(feed) valid = True - for entry in geofeed.entries(): + for entry in geofeed: if type(entry) is GeofeedValidationError: log.error(entry) valid = False diff --git a/src/ipdata/geofeeds.py b/src/ipdata/geofeeds.py index 40f98f2..f614e3f 100644 --- a/src/ipdata/geofeeds.py +++ b/src/ipdata/geofeeds.py @@ -84,7 +84,7 @@ def entries(self): path = self.cache_path # if path still contains a url it means the download was not successful in which case we yield nothing! if not path: - log.warning(f"No entries found {self.source}") + log.warning(f"No cache path for {self.source} found. Download likely failed.") return with open(path) as f: From 2c25cad92d431751785719a7f44854e78d720721 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 21:17:18 +0300 Subject: [PATCH 086/100] remove lru_cache due to unhashable arguments --- README.md | 26 +++++++++++++------------- src/ipdata/ipdata.py | 4 ---- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d298409..240489e 100644 --- a/README.md +++ b/README.md @@ -508,19 +508,6 @@ The `--fields` option is required to use CSV output. A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) -## Errors - -A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) - - -## Tests - -To run all tests - -```shell -pytest -``` - ## Geofeed tools Geofeed publishers can use the `ipdata validate` command to validate their geofeeds before submission to ipdata. This will catch most but not all issues that might cause processing your geofeed to fail. @@ -537,4 +524,17 @@ or ```shell ➜ ipdata validate geofeed.txt +``` + +## Errors + +A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) + + +## Tests + +To run all tests + +```shell +pytest ``` \ No newline at end of file diff --git a/src/ipdata/ipdata.py b/src/ipdata/ipdata.py index b5ba7c1..d2fd3c5 100644 --- a/src/ipdata/ipdata.py +++ b/src/ipdata/ipdata.py @@ -139,7 +139,6 @@ def __init__( self._session = requests.Session() self._session.mount("http", adapter) - @functools.lru_cache(maxsize=100) def _validate_fields(self, fields): """ Validates the fields passed in by the user, first ensuring it's a collection. In prior versions 'fields' was a string, however it now needs to be a collection. @@ -157,7 +156,6 @@ def _validate_fields(self, fields): f"The field(s) {diff} are not supported. Only {self.valid_fields} are supported." ) - @functools.lru_cache(maxsize=100) def _validate_ip_address(self, ip): """ Checks that 'ip' is a valid IP Address. @@ -169,7 +167,6 @@ def _validate_ip_address(self, ip): if request_ip.is_private or request_ip.is_reserved or request_ip.is_multicast: raise ValueError(f"{ip} is a reserved IP Address") - @functools.lru_cache(maxsize=100) def lookup(self, resource="", fields=[]): """ Makes a GET request to the IPData API for the specified 'resource' and the given 'fields'. @@ -219,7 +216,6 @@ def lookup(self, resource="", fields=[]): return data - @functools.lru_cache(maxsize=100) def bulk(self, resources, fields=[]): """ Lookup up to 100 resources in one request. Makes a POST request wth the resources as a JSON array and the specified fields. From ce6ef79e3e4feb6d77c122084a59c15be0f103b5 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 21:19:41 +0300 Subject: [PATCH 087/100] bump to 4.0.3 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ebfe666..24a4b7f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.2 +version = 4.0.3 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python From 4fb9bfc97d8e0addfaa02d51ca778a27b4a4486d Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 21:37:38 +0300 Subject: [PATCH 088/100] update readme --- README.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 240489e..edd09f6 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ You can look up the calling IP address, that is, the IP address of the computer You can look up any valid IPv4 or IPv6 address by passing it to the `lookup` method. ```python ->>> from rich import pprint >>> import ipdata >>> ipdata.api_key = "" >>> response = ipdata.lookup('69.78.70.144') @@ -343,7 +342,7 @@ pip install ipdata ### Available commands ```shell -➜ ipdata --help +ipdata --help Usage: ipdata [OPTIONS] COMMAND [ARGS]... Welcome to the ipdata CLI @@ -366,7 +365,7 @@ Commands: You need a valid API key from ipdata to use the cli. You can get a free key by [Signing up here](https://ipdata.co/sign-up.html). ```shell -➜ ipdata init +ipdata init _ _ _ (_)_ __ __| | __ _| |_ __ _ | | '_ \ / _` |/ _` | __/ _` | @@ -385,7 +384,7 @@ Running the `ipdata` command without any parameters will look up the IP address ```shell -➜ ipdata +ipdata ``` To pretty print the result pass the `-p` flag @@ -429,7 +428,7 @@ To pretty print the result pass the `-p` flag You can pass any valid IPv4 or IPv6 address to the `ipdata` command to look it up. In case an invalid value is passed you will get the error `ERROR 'BLEH' does not appear to be an IPv4 or IPv6 address"`. ```shell -➜ ipdata 8.8.8.8 +ipdata 8.8.8.8 ``` ### Copying results to clipboard @@ -437,7 +436,7 @@ You can pass any valid IPv4 or IPv6 address to the `ipdata` command to look it u Use `-c` to copy the results to the clipboard! ``` -➜ ipdata 1.1.1.1 -f ip -f asn -c +ipdata 1.1.1.1 -f ip -f asn -c 📋️ Copied result to clipboard! ``` @@ -446,13 +445,13 @@ Use `-c` to copy the results to the clipboard! Use `--fields` to filter the responses ```shell -➜ ipdata --fields city --fields country_name' +ipdata --fields city --fields country_name' ``` or use `-f` ```shell -➜ ipdata 1.1.1.1 -f ip -f asn +ipdata 1.1.1.1 -f ip -f asn ``` ```json @@ -517,13 +516,13 @@ The validation closely follows https://datatracker.ietf.org/doc/html/draft-googl You can provide either a url or a path to a local file. ```shell -➜ ipdata validate https://example.com/geofeed.txt +ipdata validate https://example.com/geofeed.txt ``` or ```shell -➜ ipdata validate geofeed.txt +ipdata validate geofeed.txt ``` ## Errors From 17420e9445c1eb15fc65053b575a5da47daa2048 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 15 Jul 2022 21:51:21 +0300 Subject: [PATCH 089/100] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edd09f6..76ee3b7 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ You can also pass the `--api-key ` parameter to any command to specify ### Look up your own IP address -Running the `ipdata` command without any parameters will look up the IP address of the computer you are running the command on. Alternatively you can explicitly look up your own IP address by running `ipdata me`. +Running the `ipdata` command without any parameters will look up the IP address of the computer you are running the command on. Alternatively you can explicitly look up your own IP address by running `ipdata`. ```shell From 2e5e14c9c95ed84d180350d7795d3ec8043db749 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Sat, 16 Jul 2022 12:33:49 +0300 Subject: [PATCH 090/100] fix setting region as EU --- pyproject.toml | 1 + setup.cfg | 2 +- src/ipdata/__init__.py | 3 ++- src/ipdata/ipdata.py | 7 ++++++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 40519b5..0e6543f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] requires = ["setuptools>=42"] +requires-python = ">=3.9" build-backend = "setuptools.build_meta" [tool.pytest.ini_options] diff --git a/setup.cfg b/setup.cfg index 24a4b7f..ce52c58 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.3 +version = 4.0.4 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python diff --git a/src/ipdata/__init__.py b/src/ipdata/__init__.py index f369529..2d4f6ca 100644 --- a/src/ipdata/__init__.py +++ b/src/ipdata/__init__.py @@ -10,7 +10,7 @@ # Configuration api_key = None -endpoint = None +endpoint = "https://api.ipdata.co/" default_client = None @@ -28,6 +28,7 @@ def _proxy(method, *args, **kwargs): if not default_client: default_client = IPData( api_key, + endpoint ) fn = getattr(default_client, method) diff --git a/src/ipdata/ipdata.py b/src/ipdata/ipdata.py index d2fd3c5..26b0817 100644 --- a/src/ipdata/ipdata.py +++ b/src/ipdata/ipdata.py @@ -209,6 +209,8 @@ def lookup(self, resource="", fields=[]): try: data = DotDict(response.json()) data["status"] = status_code + if "eu-api" in self.endpoint: + data["endpoint"] = "EU" except ValueError: raise IPDataException( f"An error occured while decoding the API response: {response.text}" @@ -265,9 +267,12 @@ def bulk(self, resources, fields=[]): data["status"] = status_code return data - return DotDict( + result = DotDict( { "responses": [DotDict(resource) for resource in data], "status": status_code, } ) + if "eu-api" in self.endpoint: + result["endpoint"] = "EU" + return result From 0b7c3656186211b87f55b73ce986e468656f0061 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Sat, 16 Jul 2022 12:37:04 +0300 Subject: [PATCH 091/100] remove minimum python setting --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0e6543f..40519b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,5 @@ [build-system] requires = ["setuptools>=42"] -requires-python = ">=3.9" build-backend = "setuptools.build_meta" [tool.pytest.ini_options] From bc459659f9076f5fc55d4b68d7e86ad5da97ca7f Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Wed, 20 Jul 2022 19:53:06 +0300 Subject: [PATCH 092/100] fix writing jsonl --- src/ipdata/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index a0e6c5c..0b92954 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -449,7 +449,7 @@ def batch(ctx, input, fields, output, format, exclude): for result in bulk_result.get("responses", {}): progress.update(task, advance=1) if format == "JSON": - output.write(f"{result}\n") + output.write(f"{json.dumps(result)}\n") if format == "CSV": if not csv_writer: # create writer if none exists From 1bcd11678af85ee97c113fc3ec4736ff78b59bf5 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Wed, 20 Jul 2022 19:53:38 +0300 Subject: [PATCH 093/100] bump version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ce52c58..2435ce5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.4 +version = 4.0.5 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python From 57b2008310af1183776056d690d226aa4c1554eb Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Mon, 9 Jan 2023 09:59:31 +0300 Subject: [PATCH 094/100] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 76ee3b7..4fd87c1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![PyPI version](https://badge.fury.io/py/ipdata.svg)](https://badge.fury.io/py/ipdata) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/ipdata/python/Test%20and%20Publish%20ipdata%20to%20PyPI) +[![PyPI version](https://badge.fury.io/py/ipdata.svg)](https://badge.fury.io/py/ipdata) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ipdata/python/python-publish.yml?branch=master) # Official Python client library and CLI for the ipdata API @@ -536,4 +536,4 @@ To run all tests ```shell pytest -``` \ No newline at end of file +``` From b832947819f6969899fb324c278ae86330cd0f55 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Tue, 14 Feb 2023 15:52:38 +0300 Subject: [PATCH 095/100] fix CSV writing for bulk lookups --- .gitignore | 3 ++- setup.cfg | 2 +- src/ipdata/cli.py | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 2506d72..d5f6b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ __pycache__/ *.egg-info/ *.egg *.pyc -.hypothesis/ \ No newline at end of file +.hypothesis/ +.vscode/ \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 2435ce5..2950136 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.5 +version = 4.0.6 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python diff --git a/src/ipdata/cli.py b/src/ipdata/cli.py index 0b92954..07ed0a0 100644 --- a/src/ipdata/cli.py +++ b/src/ipdata/cli.py @@ -403,7 +403,7 @@ def batch(ctx, input, fields, output, format, exclude): resources = [resource.strip() for resource in input.readlines()] bulk_results = process(resources, ipdata.bulk, fields) - # Prepare CSV writing by expanding fieldnames eg. asn to asn, name, domain etc + # Prepare CSV writing by expanding fieldnames eg. asn to [asn, name, domain] etc csv_writer = None fieldnames = [] for field in fields: @@ -413,15 +413,15 @@ def batch(ctx, input, fields, output, format, exclude): for sub_field in ["asn", "name", "domain", "route", "type"] ] continue - if field == "company": + elif field == "company": fieldnames += [ f"company_{sub_field}" - for sub_field in ["asn", "name", "domain", "network", "type"] + for sub_field in ["name", "domain", "network", "type"] ] continue - if field == "threat": + elif field == "threat": fieldnames += [ - f"asn_{sub_field}" + f"threat_{sub_field}" for sub_field in [ "is_tor", "is_icloud_relay", @@ -436,7 +436,7 @@ def batch(ctx, input, fields, output, format, exclude): ] ] continue - if field in ipdata.valid_fields: + elif field in ipdata.valid_fields: fieldnames += [field] # Do lookups concurrenctly using threads in batches of 100 each From 856ce4c33bc7a983b8ca58878ff51c5fb4b5e86c Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Tue, 14 Feb 2023 16:02:46 +0300 Subject: [PATCH 096/100] bump version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 2950136..4e61218 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.6 +version = 4.0.7 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python From 33bbb547804f9a44999bf82f3a8eecb239c1257a Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Sun, 7 Dec 2025 11:06:56 +0300 Subject: [PATCH 097/100] Added IPTrie library Added IPTrie - A fast, type-safe data structure for IP lookups with longest-prefix matching. Supports both IPv4 and IPv6. --- .gitignore | 4 +- README.md | 209 +++++++++++++++++++++ requirements.txt | 3 +- setup.cfg | 7 +- src/ipdata/__init__.py | 1 + src/ipdata/iptrie.py | 380 ++++++++++++++++++++++++++++++++++++++ src/ipdata/test_iptrie.py | 327 ++++++++++++++++++++++++++++++++ 7 files changed, 928 insertions(+), 3 deletions(-) create mode 100644 src/ipdata/iptrie.py create mode 100644 src/ipdata/test_iptrie.py diff --git a/.gitignore b/.gitignore index d5f6b9e..9e09b91 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ __pycache__/ *.egg *.pyc .hypothesis/ -.vscode/ \ No newline at end of file +.vscode/ +.pytest_cache/ +.venv/ \ No newline at end of file diff --git a/README.md b/README.md index 4fd87c1..f59dcfa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ [![PyPI version](https://badge.fury.io/py/ipdata.svg)](https://badge.fury.io/py/ipdata) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ipdata/python/python-publish.yml?branch=master) +> **🎉 Introducing IPTrie** — A fast, type-safe data structure for IP lookups with longest-prefix matching. Supports both IPv4 and IPv6. [Learn more →](#iptrie) + # Official Python client library and CLI for the ipdata API This is a Python client and command line interface (CLI) for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. @@ -10,6 +12,43 @@ Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutoria [![asciicast](https://asciinema.org/a/371292.svg)](https://asciinema.org/a/371292) +## Table of Contents + +- [Installation](#installation) +- [Library Usage](#library-usage) + - [Looking up the calling IP Address](#looking-up-the-calling-ip-address) + - [Looking up any IP Address](#looking-up-any-ip-address) + - [Getting only one field](#getting-only-one-field) + - [Getting a number of specific fields](#getting-a-number-of-specific-fields) + - [Bulk Lookups](#bulk-lookups) +- [Using the ipdata CLI](#using-the-ipdata-cli) + - [Windows Installation Notes](#windows-installation-notes) + - [Available commands](#available-commands) + - [Initialize the cli with your API Key](#initialize-the-cli-with-your-api-key) + - [Look up your own IP address](#look-up-your-own-ip-address) + - [Look up any IP address](#look-up-any-ip-address-1) + - [Copying results to clipboard](#copying-results-to-clipboard) + - [Filtering results by a list of fields](#filtering-results-by-a-list-of-fields) + - [Batch lookup](#batch-lookup) + - [Available Fields](#available-fields) +- [Geofeed tools](#geofeed-tools) +- [IPTrie](#iptrie) + - [Features](#features) + - [Quick Start](#quick-start) + - [IPv6 Support](#ipv6-support) + - [Use Cases](#use-cases) + - [Network Classification](#network-classification) + - [GeoIP Lookup](#geoip-lookup) + - [Access Control Lists](#access-control-lists) + - [API Reference](#api-reference) + - [Constructor](#constructor) + - [Methods](#methods) + - [Exceptions](#exceptions) + - [Input Validation](#input-validation) + - [Performance](#performance) +- [Errors](#errors) +- [Tests](#tests) + ## Installation Install the latest version of the cli with `pip`. @@ -525,6 +564,176 @@ or ipdata validate geofeed.txt ``` +## IPTrie + +IPTrie is a production-ready, type-safe trie for IP addresses and CIDR prefixes with longest-prefix matching. + +### Features + +- **Dual-stack support**: Handles both IPv4 and IPv6 addresses seamlessly +- **Longest-prefix matching**: Automatically finds the most specific matching prefix +- **Type-safe**: Full generic type support with comprehensive type hints +- **Pythonic API**: Familiar dictionary-like interface (`[]`, `in`, `del`, `len`, iteration) +- **Input validation**: Robust validation using Python's `ipaddress` module +- **Custom exceptions**: Clear, specific exceptions for better error handling +- **Well-tested**: Comprehensive test suite with edge cases covered + +### Quick Start + +```python +from ipdata import IPTrie + +# Create an IPTrie with string values +ip_trie: IPTrie[str] = IPTrie() + +# Add network prefixes +ip_trie["10.0.0.0/8"] = "class-a-private" +ip_trie["10.1.0.0/16"] = "datacenter" +ip_trie["10.1.1.0/24"] = "web-servers" + +# Longest-prefix matching +print(ip_trie["10.1.1.100"]) # "web-servers" +print(ip_trie["10.1.2.100"]) # "datacenter" +print(ip_trie["10.2.0.1"]) # "class-a-private" + +# Check membership +print("10.1.1.50" in ip_trie) # True +print("192.168.1.1" in ip_trie) # False + +# Get the matching prefix +print(ip_trie.parent("10.1.1.100")) # "10.1.1.0/24" + +# Safe access with default +print(ip_trie.get("8.8.8.8", "unknown")) # "unknown" +``` + +### IPv6 Support + +```python +ip_trie: IPTrie[str] = IPTrie() + +ip_trie["2001:db8::/32"] = "documentation" +ip_trie["2001:db8:1::/48"] = "specific-block" + +print(ip_trie["2001:db8:1::1"]) # "specific-block" +print(ip_trie["2001:db8:2::1"]) # "documentation" +``` + +### Use Cases + +#### Network Classification + +```python +from ipdata import IPTrie + +classifier: IPTrie[dict] = IPTrie() +classifier["10.0.0.0/8"] = {"type": "private", "rfc": "1918"} +classifier["172.16.0.0/12"] = {"type": "private", "rfc": "1918"} +classifier["192.168.0.0/16"] = {"type": "private", "rfc": "1918"} +classifier["0.0.0.0/0"] = {"type": "public", "rfc": None} + +def classify_ip(ip: str) -> dict: + return classifier.get(ip, {"type": "unknown"}) + +print(classify_ip("192.168.1.100")) # {"type": "private", "rfc": "1918"} +print(classify_ip("8.8.8.8")) # {"type": "public", "rfc": None} +``` + +#### GeoIP Lookup + +```python +from ipdata import IPTrie + +geo_db: IPTrie[str] = IPTrie() +geo_db["8.8.8.0/24"] = "US" +geo_db["1.1.1.0/24"] = "AU" + +def get_country(ip: str) -> str: + return geo_db.get(ip, "Unknown") +``` + +#### Access Control Lists + +```python +from ipdata import IPTrie + +acl: IPTrie[bool] = IPTrie() +acl["192.168.1.0/24"] = True # Allow internal +acl["10.0.0.0/8"] = True # Allow VPN +acl["0.0.0.0/0"] = False # Deny all others + +def is_allowed(ip: str) -> bool: + return acl.get(ip, False) +``` + +### API Reference + +#### Constructor + +```python +IPTrie[T]() # Create an empty IPTrie with value type T +``` + +#### Methods + +| Method | Description | +|--------|-------------| +| `__setitem__(key, value)` | Set value for IP/prefix | +| `__getitem__(key)` | Get value using longest-prefix match (raises `KeyNotFoundError`) | +| `get(key, default=None)` | Get value or default if not found | +| `__delitem__(key)` | Delete exact prefix | +| `__contains__(key)` | Check if IP matches any prefix | +| `has_key(key)` | Check if exact prefix exists | +| `parent(key)` | Get the longest matching prefix string | +| `children(key)` | Get all more specific prefixes | +| `__len__()` | Count of all prefixes | +| `__iter__()` | Iterate over all prefixes | +| `keys()` | Iterator over prefixes | +| `values()` | Iterator over values | +| `items()` | Iterator over (prefix, value) tuples | +| `clear()` | Remove all entries | + +#### Exceptions + +| Exception | Description | +|-----------|-------------| +| `IPTrieError` | Base exception class | +| `InvalidIPError` | Invalid IP address or network format | +| `KeyNotFoundError` | No matching prefix found (also a `KeyError`) | + +### Input Validation + +IPTrie validates all inputs using Python's `ipaddress` module: + +```python +from ipdata import IPTrie, InvalidIPError + +ip_trie: IPTrie[str] = IPTrie() + +# These work +ip_trie["192.168.1.0/24"] = "valid" +ip_trie["2001:db8::1"] = "valid" + +# These raise InvalidIPError +try: + ip_trie["not-an-ip"] = "invalid" +except InvalidIPError as e: + print(f"Error: {e}") + +try: + ip_trie[""] = "empty" +except InvalidIPError as e: + print(f"Error: {e}") +``` + +### Performance + +IPTrie uses Patricia tries (via `pytricia`) internally, providing: + +- **O(k)** lookup time where k is the prefix length (32 for IPv4, 128 for IPv6) +- **Memory efficient** storage of overlapping prefixes +- **Fast iteration** over all prefixes + ## Errors A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) diff --git a/requirements.txt b/requirements.txt index ae6143b..1cbcb21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ click_default_group pyperclip pytest hypothesis -python-dotenv \ No newline at end of file +python-dotenv +pytricia \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 4e61218..ebac8fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.7 +version = 4.0.8 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python @@ -11,6 +11,10 @@ project_urls = Bug Tracker = https://github.com/ipdata/python/issues classifiers = Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 License :: OSI Approved :: MIT License Operating System :: OS Independent @@ -28,6 +32,7 @@ install_requires = pytest hypothesis python-dotenv + pytricia [options.entry_points] console_scripts = diff --git a/src/ipdata/__init__.py b/src/ipdata/__init__.py index 2d4f6ca..8d521de 100644 --- a/src/ipdata/__init__.py +++ b/src/ipdata/__init__.py @@ -7,6 +7,7 @@ >>> ipdata.lookup() # or ipdata.lookup("8.8.8.8") """ from .ipdata import IPData +from .iptrie import IPTrie # Configuration api_key = None diff --git a/src/ipdata/iptrie.py b/src/ipdata/iptrie.py new file mode 100644 index 0000000..014f5f9 --- /dev/null +++ b/src/ipdata/iptrie.py @@ -0,0 +1,380 @@ +""" +IP Trie module for efficient IP address and CIDR prefix lookups. + +This module provides a dictionary-like data structure that supports both IPv4 and IPv6 +addresses with longest-prefix matching using Patricia tries. + +Example: + >>> ip_trie = IPTrie() + >>> ip_trie["192.168.0.0/16"] = "private-network" + >>> ip_trie["192.168.1.100"] + 'private-network' + >>> ip_trie.parent("192.168.1.100") + '192.168.0.0/16' +""" + +from __future__ import annotations + +import ipaddress +from collections.abc import Iterator +from typing import TypeVar, Generic + +from pytricia import PyTricia + +T = TypeVar("T") + + +class IPTrieError(Exception): + """Base exception for IPTrie errors.""" + + pass + + +class InvalidIPError(IPTrieError): + """Raised when an invalid IP address or network is provided.""" + + pass + + +class KeyNotFoundError(IPTrieError, KeyError): + """Raised when a key is not found in the trie.""" + + pass + + +def _normalize_ip_key(key: str) -> tuple[str, bool]: + """ + Validate and normalize an IP address or network string. + + Args: + key: An IP address or CIDR notation string. + + Returns: + A tuple of (normalized_key, is_ipv6). + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + """ + if not isinstance(key, str): + raise InvalidIPError(f"Key must be a string, got {type(key).__name__}") + + key = key.strip() + if not key: + raise InvalidIPError("Key cannot be empty") + + try: + if "/" in key: + network = ipaddress.ip_network(key, strict=False) + return str(network), isinstance(network, ipaddress.IPv6Network) + else: + address = ipaddress.ip_address(key) + return str(address), isinstance(address, ipaddress.IPv6Address) + except ValueError as e: + raise InvalidIPError(f"Invalid IP address or network: {key!r}") from e + + +class IPTrie(Generic[T]): + """ + A trie-based container for IP addresses and CIDR prefixes. + + Supports both IPv4 and IPv6 addresses with longest-prefix matching. + Keys can be individual IP addresses or CIDR notation networks. + + This class uses Patricia tries internally for efficient lookups, + providing O(k) lookup time where k is the prefix length. + + Attributes: + IPV4_BITS: Number of bits in IPv4 addresses (32). + IPV6_BITS: Number of bits in IPv6 addresses (128). + + Example: + >>> ip_trie = IPTrie[str]() + >>> ip_trie["10.0.0.0/8"] = "class-a-private" + >>> ip_trie["10.1.0.0/16"] = "datacenter" + >>> ip_trie["10.1.2.3"] + 'datacenter' + >>> ip_trie.parent("10.1.2.3") + '10.1.0.0/16' + """ + + IPV4_BITS: int = 32 + IPV6_BITS: int = 128 + + def __init__(self) -> None: + """Initialize an empty IPTrie.""" + self._ipv4: PyTricia = PyTricia(self.IPV4_BITS) + self._ipv6: PyTricia = PyTricia(self.IPV6_BITS) + + def _get_trie(self, is_ipv6: bool) -> PyTricia: + """Return the appropriate trie based on IP version.""" + return self._ipv6 if is_ipv6 else self._ipv4 + + def __setitem__(self, key: str, value: T) -> None: + """ + Set a value for an IP address or network prefix. + + Args: + key: An IP address or CIDR notation network string. + value: The value to associate with the key. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie["192.168.1.0/24"] = "local-network" + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + self._get_trie(is_ipv6)[normalized_key] = value + + def __getitem__(self, key: str) -> T: + """ + Get the value for an IP address using longest-prefix matching. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + The value associated with the longest matching prefix. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + KeyNotFoundError: If no matching prefix is found. + + Example: + >>> ip_trie["192.168.1.100"] + 'local-network' + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + if normalized_key not in trie: + raise KeyNotFoundError(key) + + return trie[normalized_key] + + def get(self, key: str, default: T | None = None) -> T | None: + """ + Get the value for an IP address, returning default if not found. + + Args: + key: An IP address or CIDR notation network string. + default: Value to return if key is not found. + + Returns: + The value associated with the longest matching prefix, + or the default value if not found. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.get("8.8.8.8", "unknown") + 'unknown' + """ + try: + return self[key] + except KeyNotFoundError: + return default + + def __delitem__(self, key: str) -> None: + """ + Delete an exact prefix from the trie. + + Args: + key: An IP address or CIDR notation network string. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + KeyNotFoundError: If the exact prefix is not found. + + Example: + >>> del ip_trie["192.168.1.0/24"] + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + if not trie.has_key(normalized_key): + raise KeyNotFoundError(key) + + del trie[normalized_key] + + def __contains__(self, key: object) -> bool: + """ + Check if an IP address matches any prefix in the trie. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + True if a matching prefix exists, False otherwise. + + Example: + >>> "192.168.1.100" in ip_trie + True + """ + if not isinstance(key, str): + return False + + try: + normalized_key, is_ipv6 = _normalize_ip_key(key) + return normalized_key in self._get_trie(is_ipv6) + except InvalidIPError: + return False + + def has_key(self, key: str) -> bool: + """ + Check if an exact prefix exists in the trie. + + Unlike __contains__, this checks for an exact match rather than + longest-prefix matching. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + True if the exact prefix exists, False otherwise. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.has_key("192.168.1.0/24") + True + >>> ip_trie.has_key("192.168.1.0/25") + False + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + return self._get_trie(is_ipv6).has_key(normalized_key) + + def parent(self, key: str) -> str | None: + """ + Get the longest matching prefix for an IP address. + + Args: + key: An IP address or CIDR notation network string. + + Returns: + The longest matching prefix as a string, or None if not found. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.parent("192.168.1.100") + '192.168.1.0/24' + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + if normalized_key not in trie: + return None + + return trie.get_key(normalized_key) + + def children(self, key: str) -> list[str]: + """ + Get all prefixes that are more specific than the given prefix. + + Args: + key: A CIDR notation network string. + + Returns: + A list of all more specific prefixes. + + Raises: + InvalidIPError: If the key is not a valid IP address or network. + + Example: + >>> ip_trie.children("192.168.0.0/16") + ['192.168.1.0/24', '192.168.2.0/24'] + """ + normalized_key, is_ipv6 = _normalize_ip_key(key) + trie = self._get_trie(is_ipv6) + + try: + return list(trie.children(normalized_key)) + except KeyError: + return [] + + def __len__(self) -> int: + """ + Return the total number of prefixes stored. + + Returns: + The combined count of IPv4 and IPv6 prefixes. + + Example: + >>> len(ip_trie) + 3 + """ + return len(self._ipv4) + len(self._ipv6) + + def __iter__(self) -> Iterator[str]: + """ + Iterate over all prefixes in the trie. + + Yields: + All stored prefixes (IPv4 first, then IPv6). + + Example: + >>> list(ip_trie) + ['192.168.1.0/24', '10.0.0.0/8', '2001:db8::/32'] + """ + yield from self._ipv4 + yield from self._ipv6 + + def keys(self) -> Iterator[str]: + """ + Return an iterator over all prefixes. + + Yields: + All stored prefixes. + """ + return iter(self) + + def values(self) -> Iterator[T]: + """ + Return an iterator over all values. + + Yields: + All stored values. + """ + for key in self: + try: + if ":" in key: + yield self._ipv6[key] + else: + yield self._ipv4[key] + except KeyError: + continue + + def items(self) -> Iterator[tuple[str, T]]: + """ + Return an iterator over all (prefix, value) pairs. + + Yields: + Tuples of (prefix, value). + """ + for key in self: + try: + if ":" in key: + yield key, self._ipv6[key] + else: + yield key, self._ipv4[key] + except KeyError: + continue + + def clear(self) -> None: + """Remove all entries from the trie.""" + self._ipv4 = PyTricia(self.IPV4_BITS) + self._ipv6 = PyTricia(self.IPV6_BITS) + + def __repr__(self) -> str: + """Return a string representation of the IPTrie.""" + ipv4_count = len(self._ipv4) + ipv6_count = len(self._ipv6) + return f"IPTrie(ipv4_prefixes={ipv4_count}, ipv6_prefixes={ipv6_count})" + + def __bool__(self) -> bool: + """Return True if the trie contains any entries.""" + return len(self) > 0 \ No newline at end of file diff --git a/src/ipdata/test_iptrie.py b/src/ipdata/test_iptrie.py new file mode 100644 index 0000000..4b76292 --- /dev/null +++ b/src/ipdata/test_iptrie.py @@ -0,0 +1,327 @@ +""" +Unit tests for the IPTrie module. + +Run with: pytest test_iptrie.py -v +""" + +import pytest + +from .iptrie import IPTrie, InvalidIPError, KeyNotFoundError, _normalize_ip_key + + +class TestNormalizeIPKey: + """Tests for the _normalize_ip_key helper function.""" + + def test_valid_ipv4_address(self): + key, is_ipv6 = _normalize_ip_key("192.168.1.1") + assert key == "192.168.1.1" + assert is_ipv6 is False + + def test_valid_ipv4_network(self): + key, is_ipv6 = _normalize_ip_key("192.168.1.0/24") + assert key == "192.168.1.0/24" + assert is_ipv6 is False + + def test_ipv4_network_normalization(self): + # Non-strict mode normalizes host bits + key, is_ipv6 = _normalize_ip_key("192.168.1.100/24") + assert key == "192.168.1.0/24" + assert is_ipv6 is False + + def test_valid_ipv6_address(self): + key, is_ipv6 = _normalize_ip_key("2001:db8::1") + assert key == "2001:db8::1" + assert is_ipv6 is True + + def test_valid_ipv6_network(self): + key, is_ipv6 = _normalize_ip_key("2001:db8::/32") + assert key == "2001:db8::/32" + assert is_ipv6 is True + + def test_ipv6_normalization(self): + key, is_ipv6 = _normalize_ip_key("2001:0db8:0000:0000:0000:0000:0000:0001") + assert key == "2001:db8::1" + assert is_ipv6 is True + + def test_whitespace_handling(self): + key, is_ipv6 = _normalize_ip_key(" 192.168.1.1 ") + assert key == "192.168.1.1" + + def test_invalid_ip_raises_error(self): + with pytest.raises(InvalidIPError, match="Invalid IP address"): + _normalize_ip_key("not-an-ip") + + def test_empty_string_raises_error(self): + with pytest.raises(InvalidIPError, match="cannot be empty"): + _normalize_ip_key("") + + def test_non_string_raises_error(self): + with pytest.raises(InvalidIPError, match="must be a string"): + _normalize_ip_key(12345) # type: ignore + + def test_none_raises_error(self): + with pytest.raises(InvalidIPError, match="must be a string"): + _normalize_ip_key(None) # type: ignore + + +class TestIPTrieBasicOperations: + """Tests for basic trie operations.""" + + def test_setitem_and_getitem_ipv4(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.0/24"] = "local-network" + assert ip_trie["192.168.1.100"] == "local-network" + + def test_setitem_and_getitem_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["2001:db8::/32"] = "documentation" + assert ip_trie["2001:db8::1"] == "documentation" + + def test_getitem_not_found_raises_error(self): + ip_trie: IPTrie[str] = IPTrie() + with pytest.raises(KeyNotFoundError): + _ = ip_trie["192.168.1.1"] + + def test_get_with_default(self): + ip_trie: IPTrie[str] = IPTrie() + assert ip_trie.get("192.168.1.1") is None + assert ip_trie.get("192.168.1.1", "default") == "default" + + def test_get_existing_key(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "class-a" + assert ip_trie.get("10.1.2.3") == "class-a" + + def test_contains_ipv4(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.0.0/16"] = "private" + assert "192.168.1.1" in ip_trie + assert "10.0.0.1" not in ip_trie + + def test_contains_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["2001:db8::/32"] = "docs" + assert "2001:db8::1" in ip_trie + assert "2001:db9::1" not in ip_trie + + def test_contains_invalid_key_returns_false(self): + ip_trie: IPTrie[str] = IPTrie() + assert "not-an-ip" not in ip_trie + assert 12345 not in ip_trie # type: ignore + + def test_delitem(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.0/24"] = "test" + assert "192.168.1.1" in ip_trie + del ip_trie["192.168.1.0/24"] + assert "192.168.1.1" not in ip_trie + + def test_delitem_not_found_raises_error(self): + ip_trie: IPTrie[str] = IPTrie() + with pytest.raises(KeyNotFoundError): + del ip_trie["192.168.1.0/24"] + + def test_len(self): + ip_trie: IPTrie[str] = IPTrie() + assert len(ip_trie) == 0 + ip_trie["10.0.0.0/8"] = "a" + assert len(ip_trie) == 1 + ip_trie["192.168.0.0/16"] = "b" + assert len(ip_trie) == 2 + ip_trie["2001:db8::/32"] = "c" + assert len(ip_trie) == 3 + + def test_bool_empty(self): + ip_trie: IPTrie[str] = IPTrie() + assert not ip_trie + + def test_bool_non_empty(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "test" + assert ip_trie + + +class TestIPTrieLongestPrefixMatching: + """Tests for longest-prefix matching behavior.""" + + def test_longest_prefix_match_ipv4(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "broad" + ip_trie["10.1.0.0/16"] = "narrower" + ip_trie["10.1.1.0/24"] = "specific" + + assert ip_trie["10.1.1.100"] == "specific" + assert ip_trie["10.1.2.100"] == "narrower" + assert ip_trie["10.2.0.1"] == "broad" + + def test_longest_prefix_match_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["2001:db8::/32"] = "broad" + ip_trie["2001:db8:1::/48"] = "specific" + + assert ip_trie["2001:db8:1::1"] == "specific" + assert ip_trie["2001:db8:2::1"] == "broad" + + def test_parent_returns_matching_prefix(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.0.0/16"] = "network" + ip_trie["192.168.1.0/24"] = "subnet" + + assert ip_trie.parent("192.168.1.100") == "192.168.1.0/24" + assert ip_trie.parent("192.168.2.100") == "192.168.0.0/16" + + def test_parent_not_found(self): + ip_trie: IPTrie[str] = IPTrie() + assert ip_trie.parent("192.168.1.1") is None + + +class TestIPTrieHasKey: + """Tests for exact prefix matching.""" + + def test_has_key_exact_match(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.0/24"] = "test" + + assert ip_trie.has_key("192.168.1.0/24") is True + assert ip_trie.has_key("192.168.1.0/25") is False + assert ip_trie.has_key("192.168.1.100") is False + + +class TestIPTrieIteration: + """Tests for iteration methods.""" + + def test_iter(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + ip_trie["192.168.0.0/16"] = "b" + ip_trie["2001:db8::/32"] = "c" + + keys = list(ip_trie) + assert len(keys) == 3 + assert "10.0.0.0/8" in keys + assert "192.168.0.0/16" in keys + assert "2001:db8::/32" in keys + + def test_keys(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + keys = list(ip_trie.keys()) + assert keys == ["10.0.0.0/8"] + + def test_values(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "value-a" + ip_trie["192.168.0.0/16"] = "value-b" + values = list(ip_trie.values()) + assert set(values) == {"value-a", "value-b"} + + def test_items(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "value-a" + items = list(ip_trie.items()) + assert ("10.0.0.0/8", "value-a") in items + + +class TestIPTrieChildren: + """Tests for the children method.""" + + def test_children_returns_more_specific_prefixes(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "parent" + ip_trie["10.1.0.0/16"] = "child1" + ip_trie["10.2.0.0/16"] = "child2" + ip_trie["192.168.0.0/16"] = "other" + + children = ip_trie.children("10.0.0.0/8") + assert len(children) == 2 + assert "10.1.0.0/16" in children + assert "10.2.0.0/16" in children + + def test_children_no_children(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "test" + children = ip_trie.children("10.1.0.0/16") + assert children == [] + + +class TestIPTrieClear: + """Tests for the clear method.""" + + def test_clear_removes_all_entries(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + ip_trie["2001:db8::/32"] = "b" + assert len(ip_trie) == 2 + + ip_trie.clear() + assert len(ip_trie) == 0 + assert "10.0.0.1" not in ip_trie + + +class TestIPTrieRepr: + """Tests for string representation.""" + + def test_repr_empty(self): + ip_trie: IPTrie[str] = IPTrie() + assert repr(ip_trie) == "IPTrie(ipv4_prefixes=0, ipv6_prefixes=0)" + + def test_repr_with_entries(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "a" + ip_trie["192.168.0.0/16"] = "b" + ip_trie["2001:db8::/32"] = "c" + assert repr(ip_trie) == "IPTrie(ipv4_prefixes=2, ipv6_prefixes=1)" + + +class TestIPTrieValueTypes: + """Tests for different value types.""" + + def test_dict_values(self): + ip_trie: IPTrie[dict] = IPTrie() + ip_trie["10.0.0.0/8"] = {"name": "class-a", "owner": "acme"} + result = ip_trie["10.1.2.3"] + assert result == {"name": "class-a", "owner": "acme"} + + def test_list_values(self): + ip_trie: IPTrie[list[str]] = IPTrie() + ip_trie["10.0.0.0/8"] = ["tag1", "tag2"] + result = ip_trie["10.1.2.3"] + assert result == ["tag1", "tag2"] + + def test_none_value(self): + ip_trie: IPTrie[None] = IPTrie() + ip_trie["10.0.0.0/8"] = None + assert ip_trie["10.1.2.3"] is None + + +class TestIPTrieEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_host_address_as_key(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["192.168.1.1"] = "single-host" + assert ip_trie["192.168.1.1"] == "single-host" + assert "192.168.1.2" not in ip_trie + + def test_default_routes(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["0.0.0.0/0"] = "default-v4" + ip_trie["::/0"] = "default-v6" + + assert ip_trie["8.8.8.8"] == "default-v4" + assert ip_trie["2001:4860:4860::8888"] == "default-v6" + + def test_overwrite_value(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["10.0.0.0/8"] = "original" + ip_trie["10.0.0.0/8"] = "updated" + assert ip_trie["10.1.2.3"] == "updated" + + def test_ipv4_mapped_ipv6(self): + ip_trie: IPTrie[str] = IPTrie() + ip_trie["::ffff:192.168.1.0/120"] = "mapped" + assert ip_trie["::ffff:192.168.1.1"] == "mapped" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file From c39c826d12226e547ba568b3396ce9814a026da3 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Sun, 7 Dec 2025 11:49:41 +0300 Subject: [PATCH 098/100] update workflows --- .github/workflows/python-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 2229ba2..714a09e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -7,9 +7,9 @@ jobs: name: Build and publish to PyPI runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - name: Set up Python 3.10.4 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.10.4 - name: Install dependencies @@ -35,7 +35,7 @@ jobs: . - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_PASSWORD }} From 53582e1405a3628b97c5f8fb77240b27d27b6daf Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 19 Dec 2025 09:45:29 +0300 Subject: [PATCH 099/100] Improve IPTrie performance by using socket.inet_pton over ipaddress.ip_network --- src/ipdata/iptrie.py | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/ipdata/iptrie.py b/src/ipdata/iptrie.py index 014f5f9..c6b6102 100644 --- a/src/ipdata/iptrie.py +++ b/src/ipdata/iptrie.py @@ -16,6 +16,7 @@ from __future__ import annotations import ipaddress +import socket from collections.abc import Iterator from typing import TypeVar, Generic @@ -41,20 +42,7 @@ class KeyNotFoundError(IPTrieError, KeyError): pass - def _normalize_ip_key(key: str) -> tuple[str, bool]: - """ - Validate and normalize an IP address or network string. - - Args: - key: An IP address or CIDR notation string. - - Returns: - A tuple of (normalized_key, is_ipv6). - - Raises: - InvalidIPError: If the key is not a valid IP address or network. - """ if not isinstance(key, str): raise InvalidIPError(f"Key must be a string, got {type(key).__name__}") @@ -62,15 +50,19 @@ def _normalize_ip_key(key: str) -> tuple[str, bool]: if not key: raise InvalidIPError("Key cannot be empty") + addr = key.rsplit("/", 1)[0] if "/" in key else key + + try: + socket.inet_pton(socket.AF_INET, addr) + return key, False + except socket.error: + pass + try: - if "/" in key: - network = ipaddress.ip_network(key, strict=False) - return str(network), isinstance(network, ipaddress.IPv6Network) - else: - address = ipaddress.ip_address(key) - return str(address), isinstance(address, ipaddress.IPv6Address) - except ValueError as e: - raise InvalidIPError(f"Invalid IP address or network: {key!r}") from e + socket.inet_pton(socket.AF_INET6, addr) + return key, True + except socket.error: + raise InvalidIPError(f"Invalid IP address or network: {key!r}") class IPTrie(Generic[T]): From d0e7e487f1f38d55cd9b3a81ec7a9ae238a08fd0 Mon Sep 17 00:00:00 2001 From: Jonathan Kosgei Date: Fri, 19 Dec 2025 09:45:59 +0300 Subject: [PATCH 100/100] bump version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ebac8fd..df6fb94 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = ipdata -version = 4.0.8 +version = 4.0.9 author = Jonathan Kosgei author_email = jonathan@ipdata.co description = This is the official IPData client library for Python

EQV9P{|NHmu{}L9I5dU@m>;LBY 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 diff --git a/dist/ipdata-3.0-py3-none-any.whl b/dist/ipdata-3.0-py3-none-any.whl deleted file mode 100644 index 38f562334988bdf84f3441dd29b08fa0a8a0d2b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5497 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 J&QJS&_J0i4jxqoM diff --git a/dist/ipdata-3.0.tar.gz b/dist/ipdata-3.0.tar.gz deleted file mode 100644 index 8884ec2c24fd2fb49bcd091048879b8b740658f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34704 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 diff --git a/dist/ipdata-3.1-py3-none-any.whl b/dist/ipdata-3.1-py3-none-any.whl deleted file mode 100644 index ad9e1a6855871621a16b7b53256e6ebd24b86355..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5460 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 z 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 diff --git a/dist/ipdata-3.2-py3-none-any.whl b/dist/ipdata-3.2-py3-none-any.whl deleted file mode 100644 index b5a6bcc32967fb803c3995def7b686c4b6362087..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5461 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 diff --git a/dist/ipdata-3.2.tar.gz b/dist/ipdata-3.2.tar.gz deleted file mode 100644 index 998b7670ba20e952956a6572ce9c07ba4520da02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34646 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 n`Y0GUE| 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 diff --git a/dist/ipdata-3.3.1-py3-none-any.whl b/dist/ipdata-3.3.1-py3-none-any.whl deleted file mode 100644 index cb94e82f6eb181855ede781a3b2cb26c8d01e584..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8754 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 diff --git a/dist/ipdata-3.3.1.tar.gz b/dist/ipdata-3.3.1.tar.gz deleted file mode 100644 index a2adbd147b9ed06cf3b7e66f1242f83e49045822..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38693 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 diff --git a/ipdata.egg-info/PKG-INFO b/ipdata.egg-info/PKG-INFO deleted file mode 100644 index d6e0f2e..0000000 --- a/ipdata.egg-info/PKG-INFO +++ /dev/null @@ -1,287 +0,0 @@ -Metadata-Version: 2.1 -Name: ipdata -Version: 3.3.1 -Summary: Python Client for the ipdata IP Geolocation API -Home-page: https://github.com/ipdata/python -Author: Jonathan Kosgei -Author-email: jonatha@ipdata.co -License: MIT -Description: # Getting Started - - This is a Python client for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. - - Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, sign up at https://ipdata.co/registration.html. If you need higher volume, sign up for your preferred plan at https://ipdata.co/pricing.html. - - Visit our [Documentation](https://docs.ipdata.co/) for more information. - - ## Installation - - ``` - pip3 install ipdata - ``` - - ## Usage - - ### Looking Up the Calling IP Address - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup() - pprint(response) - ``` - - ### Looking Up any IP Address - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup('69.78.70.144') - pprint(response) - ``` - - Response - - ``` - {'asn': 'AS6167', - 'calling_code': '1', - 'carrier': {'mcc': '310', 'mnc': '004', 'name': 'Verizon'}, - 'city': 'Farmersville', - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': 'рџ‡єрџ‡ё', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '69.78.70.144', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 33.1659, - 'longitude': -96.3686, - 'organisation': 'Cellco Partnership DBA Verizon Wireless', - 'postal': '75442', - 'region': 'Texas', - 'region_code': 'TX', - 'status': 200, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T17:56:59.246755-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}} - ``` - - ### Getting only one field - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup('8.8.8.8', select_field='organisation') - pprint(response) - ``` - - Response - - ``` - {'organisation': 'Google LLC', 'status': 200} - ``` - - ### Getting a number of specific fields - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.lookup('8.8.8.8',fields=['ip','organisation','country_name']) - pprint(response) - ``` - - Response - - ``` - {'country_name': 'United States', - 'ip': '8.8.8.8', - 'organisation': 'Google LLC', - 'status': 200} - ``` - - ### Bulk Lookups - - ``` - from ipdata import ipdata - from pprint import pprint - # Create an instance of an ipdata object. Replace `test` with your API Key - ipdata = ipdata.IPData('test') - response = ipdata.bulk_lookup(['8.8.8.8','1.1.1.1']) - pprint(response) - ``` - - Response - - ``` - {'responses': [{'asn': 'AS15169', - 'calling_code': '1', - 'city': None, - 'continent_code': 'NA', - 'continent_name': 'North America', - 'count': '1506', - 'country_code': 'US', - 'country_name': 'United States', - 'currency': {'code': 'USD', - 'name': 'US Dollar', - 'native': '$', - 'plural': 'US dollars', - 'symbol': '$'}, - 'emoji_flag': 'рџ‡єрџ‡ё', - 'emoji_unicode': 'U+1F1FA U+1F1F8', - 'flag': 'https://ipdata.co/flags/us.png', - 'ip': '8.8.8.8', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': 37.751, - 'longitude': -97.822, - 'organisation': 'Google LLC', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'CDT', - 'current_time': '2019-04-28T18:02:48.035425-05:00', - 'is_dst': True, - 'name': 'America/Chicago', - 'offset': '-0500'}}, - {'asn': 'AS13335', - 'calling_code': '61', - 'city': None, - 'continent_code': 'OC', - 'continent_name': 'Oceania', - 'count': '1506', - 'country_code': 'AU', - 'country_name': 'Australia', - 'currency': {'code': 'AUD', - 'name': 'Australian Dollar', - 'native': '$', - 'plural': 'Australian dollars', - 'symbol': 'AU$'}, - 'emoji_flag': '🇦🇺', - 'emoji_unicode': 'U+1F1E6 U+1F1FA', - 'flag': 'https://ipdata.co/flags/au.png', - 'ip': '1.1.1.1', - 'is_eu': False, - 'languages': [{'name': 'English', 'native': 'English'}], - 'latitude': -33.494, - 'longitude': 143.2104, - 'organisation': 'Cloudflare, Inc.', - 'postal': None, - 'region': None, - 'region_code': None, - 'threat': {'is_anonymous': False, - 'is_bogon': False, - 'is_known_abuser': False, - 'is_known_attacker': False, - 'is_proxy': False, - 'is_threat': False, - 'is_tor': False}, - 'time_zone': {'abbr': 'AEST', - 'current_time': '2019-04-29T09:02:48.036287+10:00', - 'is_dst': False, - 'name': 'Australia/Sydney', - 'offset': '+1000'}}], - 'status': 200} - ``` - - ## Available Fields - - A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields) - - ## Errors - - A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes) - - ## Tests - - To run all tests - - ``` - python3 test_ipdata.py - ``` - - ## ipdata CLI - - Usage: `ipdata [OPTIONS] COMMAND [ARGS]...` - - Options: - `--api-key` TEXT IPData API Key - - Commands: - `batch` - `info` - `init` - `me` - - ### ipdata CLI Examples - - #### Initialize with API Key - ``` - ipdata init - ``` - You may also pass `--api-key ` extra param to any command to - specify API Key. - - #### Lookup your own IP address - ``` - ipdata - ``` - or - ``` - ipdata me - ``` - #### Look up an IP address - ``` - ipdata - ``` - #### Look up an I address and filter result by specifying coma separated list of fields - ``` - ipdata --fields ip,country_code - ``` - #### Batch lookup - ``` - ipdata --output - ``` - #### Batch lookup with output to CSV file - ``` - ipdata --output --output-format CSV --fields ip,country_code - ``` - `--fields` option is required in case of CSV output. - -Platform: UNKNOWN -Classifier: License :: OSI Approved :: MIT License -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.7 -Description-Content-Type: text/markdown diff --git a/ipdata.egg-info/SOURCES.txt b/ipdata.egg-info/SOURCES.txt deleted file mode 100644 index 3541ed1..0000000 --- a/ipdata.egg-info/SOURCES.txt +++ /dev/null @@ -1,33 +0,0 @@ -LICENSE.txt -MANIFEST -README.md -requirements.txt -setup.cfg -setup.py -dist/ipdata-1.0.tar.gz -dist/ipdata-1.1.tar.gz -dist/ipdata-1.2.tar.gz -dist/ipdata-1.3.tar.gz -dist/ipdata-1.4.tar.gz -dist/ipdata-1.5.tar.gz -dist/ipdata-1.6.tar.gz -dist/ipdata-1.7.tar.gz -dist/ipdata-1.8.tar.gz -dist/ipdata-1.9.tar.gz -dist/ipdata-2.0.tar.gz -dist/ipdata-2.1.tar.gz -dist/ipdata-2.2.tar.gz -dist/ipdata-2.3.tar.gz -dist/ipdata-2.4.tar.gz -dist/ipdata-2.5.tar.gz -ipdata/__init__.py -ipdata/cli.py -ipdata/ipdata.py -ipdata/test_cli.py -ipdata/test_ipdata.py -ipdata.egg-info/PKG-INFO -ipdata.egg-info/SOURCES.txt -ipdata.egg-info/dependency_links.txt -ipdata.egg-info/entry_points.txt -ipdata.egg-info/requires.txt -ipdata.egg-info/top_level.txt \ No newline at end of file diff --git a/ipdata.egg-info/dependency_links.txt b/ipdata.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/ipdata.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ipdata.egg-info/entry_points.txt b/ipdata.egg-info/entry_points.txt deleted file mode 100644 index 72fdd24..0000000 --- a/ipdata.egg-info/entry_points.txt +++ /dev/null @@ -1,3 +0,0 @@ -[console_scripts] -ipdata = ipdata.cli:todo - diff --git a/ipdata.egg-info/requires.txt b/ipdata.egg-info/requires.txt deleted file mode 100644 index 9300b51..0000000 --- a/ipdata.egg-info/requires.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests -ipaddress -click diff --git a/ipdata.egg-info/top_level.txt b/ipdata.egg-info/top_level.txt deleted file mode 100644 index 3eb32e1..0000000 --- a/ipdata.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -ipdata diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" From 3263e344a50ed87c298dd0b34eb906b1347dd8cb Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 6 Nov 2020 15:38:22 +0300 Subject: [PATCH 004/100] Minor readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d8fa77..4e9d061 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Getting Started -This is a Python client for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. +This is a Python client and command line interface (CLI) for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, sign up at https://ipdata.co/registration.html. If you need higher volume, sign up for your preferred plan at https://ipdata.co/pricing.html. From 33f93598f2efd716ed94a9e97dc629809da9eade Mon Sep 17 00:00:00 2001 From: Stas Davydov Date: Fri, 6 Nov 2020 15:41:17 +0300 Subject: [PATCH 005/100] Minor readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e9d061..0444bcf 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ python3 test_ipdata.py Usage: `ipdata [OPTIONS] COMMAND [ARGS]...` Options: - `--api-key` TEXT IPData API Key + `--api-key` TEXT IPData API Key Commands: `batch` From 06c5951a1b2f94ff9314d22589a4281797531b58 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 10:35:32 +0300 Subject: [PATCH 006/100] Fix action --- .github/workflows/python-publish.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f5bb677..35aca8a 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v1 with: - python-version: 3.7 + python-version: 3.8 - name: Install pep517 run: >- python -m @@ -26,15 +26,9 @@ jobs: --binary --out-dir dist/ . - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.test_pypi_password }} - repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: user: __token__ - password: ${{ secrets.pypi_password }} + password: ${{ secrets.PYPI_USERNAME }} From d468fcb5f1e9f47f636f0a8ea7d0fd476d0e5f67 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 10:40:37 +0300 Subject: [PATCH 007/100] Fix action --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 35aca8a..300d8a8 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -31,4 +31,4 @@ jobs: uses: pypa/gh-action-pypi-publish@master with: user: __token__ - password: ${{ secrets.PYPI_USERNAME }} + password: ${{ secrets.PYPI_PASSWORD }} From d192079b2ca835996df841c748fd76ae1110d66c Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 11:03:08 +0300 Subject: [PATCH 008/100] Added ci tests --- .github/workflows/python-publish.yml | 16 ++++++++++++---- .gitignore | 1 + ipdata/test_cli.py | 3 +++ ipdata/test_ipdata.py | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 300d8a8..758aeda 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,17 +1,25 @@ -name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI +name: Publish ipdata to PyPI on: push jobs: build-n-publish: - name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + name: Build and publish to PyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python 3.8 - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install unittest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test with unittest + run: | + python -m unittest - name: Install pep517 run: >- python -m @@ -26,7 +34,7 @@ jobs: --binary --out-dir dist/ . - - name: Publish distribution 📦 to PyPI + - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: diff --git a/.gitignore b/.gitignore index a68b1ea..8334094 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ /*.egg-info/ /*.egg *.pyc +ipdata/__pycache__ \ No newline at end of file diff --git a/ipdata/test_cli.py b/ipdata/test_cli.py index b1a56a0..228bd74 100644 --- a/ipdata/test_cli.py +++ b/ipdata/test_cli.py @@ -18,3 +18,6 @@ def test_json_filter(self): res = json_filter(json, ('d',)) self.assertDictEqual({'d': 3}, res) + +if __name__ == '__main__': + unittest.main() diff --git a/ipdata/test_ipdata.py b/ipdata/test_ipdata.py index 14631e7..1533b3f 100644 --- a/ipdata/test_ipdata.py +++ b/ipdata/test_ipdata.py @@ -1,4 +1,4 @@ -from ipdata import * +from .ipdata import * import unittest class TestAPIMethods(unittest.TestCase): From 299a3907565005a75f848031f864313d3e12c1a8 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 11:05:10 +0300 Subject: [PATCH 009/100] Fix ci syntax --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 758aeda..72e0e0c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,7 +17,7 @@ jobs: python -m pip install --upgrade pip pip install unittest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Test with unittest + - name: Test with unittest run: | python -m unittest - name: Install pep517 From 74dbbb554da7cd278bec69717cf55ecc1b12a6a7 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 11:06:28 +0300 Subject: [PATCH 010/100] Fix ci command --- .github/workflows/python-publish.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 72e0e0c..aa71836 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,7 +15,6 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install unittest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with unittest run: | From 3497baba94663617de73e8123e387c2e6199fd06 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 11:07:47 +0300 Subject: [PATCH 011/100] Updated workflow name --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index aa71836..ab8f3a5 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,4 +1,4 @@ -name: Publish ipdata to PyPI +name: Test and Publish ipdata to PyPI on: push From 59ee7c9fa82a7459f5e05948d21de8a285c0eb52 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 8 Nov 2020 12:19:06 +0300 Subject: [PATCH 012/100] Update init command --- README.md | 85 ++++++++++++++++++++++++++++++--------------------- ipdata/cli.py | 12 ++------ 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 0444bcf..dcd07d4 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ This is a Python client and command line interface (CLI) for the [ipdata.co](https://ipdata.co) IP Geolocation API. ipdata offers a fast, highly-available API to enrich IP Addresses with Location, Company, Threat Intelligence and numerous other data attributes. -Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, sign up at https://ipdata.co/registration.html. If you need higher volume, sign up for your preferred plan at https://ipdata.co/pricing.html. +Note you need an API Key to access the API. To get a key on the 1500 requests a day free tier, [Sign up here](https://ipdata.co/sign-up.html) . If you need higher volume then [Sign up for a paid plan](https://ipdata.co/pricing.html). -Visit our [Documentation](https://docs.ipdata.co/) for more information. +Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutorials. ## Installation ``` -pip3 install ipdata +pip install ipdata ``` -## Usage +## Library Usage ### Looking Up the Calling IP Address @@ -36,8 +36,7 @@ response = ipdata.lookup('69.78.70.144') pprint(response) ``` -Response - +