├── .github └── ISSUE_TEMPLATE │ ├── pyspy-bug-report.md │ └── pyspy-feature-request.md ├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── VERSION ├── __main__.py ├── __main__.spec ├── aboutdialog.py ├── analyze.py ├── apis.py ├── assets ├── cov_cyno_64.png ├── hic_64.png ├── norm_cyno_64.png ├── pyspy.icns ├── pyspy.ico ├── pyspy.png ├── pyspy_mid.png ├── pyspy_small.png ├── v0.2_screenshot.png ├── v0.3_dark_screenshot.png ├── v0.3_light_screenshot.png ├── v0.4_dark_screenshot.png └── v0.4_light_screenshot.png ├── chkversion.py ├── config.py ├── db.py ├── gui.py ├── highlightdialog.py ├── ignoredialog.py ├── optstore.py ├── reportstats.py ├── requirements.txt ├── sortarray.py └── statusmsg.py /.github/ISSUE_TEMPLATE/pyspy-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: PySpy Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **System Environment:** 24 | - OS: [e.g. macOS, Windows] 25 | - OS Version: [e.g. High Sierra, 10] 26 | - PySpy Version [e.g. 0.1] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pyspy-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: PySpy Feature request 3 | about: Suggest improvements to PySpy 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom folders 2 | other/ 3 | tmp/ 4 | download/ 5 | sql/ 6 | stats_server/ 7 | dll/ 8 | 9 | # macOS stuff 10 | .DS_Store 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | # *.spec 41 | build/ 42 | 43 | # Jetbrains IDE stuff 44 | .idea/ 45 | 46 | # venv 47 | venv/ 48 | 49 | *.pdf 50 | *.db* 51 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Discussed at https://travis-ci.community/t/python-support-on-windows/241/18 2 | language: python # this works for Linux but is an error on macOS or Windows 3 | matrix: 4 | include: 5 | - name: "Python 3.7.2 on macOS" 6 | os: osx 7 | osx_image: xcode11 # Python 3.7.2 running on macOS 10.14.3 8 | language: shell # 'language: python' is an error on Travis CI macOS 9 | # python: 3.7 # 'python:' is ignored on Travis CI macOS 10 | before_install: python3 --version ; pip3 --version ; sw_vers 11 | - name: "Python 3.7.4 on Windows" 12 | os: windows # Windows 10.0.17134 N/A Build 17134 13 | language: shell # 'language: python' is an error on Travis CI Windows 14 | # python: 3.7 # 'python:' is ignored on Travis CI Windows 15 | before_install: 16 | - choco install python3 --version=3.7.4 # this install takes at least 1 min 30 sec 17 | - python -m pip install --upgrade pip 18 | env: PATH=/c/Python37:/c/Python37/Scripts:$PATH 19 | install: 20 | - pip3 install --upgrade pip # all three OSes agree about 'pip3' 21 | - pip3 install pathlib2 22 | - pip3 install -r requirements.txt 23 | - pip3 install pyinstaller==3.5 24 | script: pyinstaller __main__.spec 25 | notifications: 26 | email: false 27 | 28 | before_deploy: 29 | - ls dist/ 30 | - wget "http://s000.tinyupload.com/download.php?file_id=57916265111510766893&t=5791626511151076689386010" -O dist/README.pdf 31 | - if [ "$TRAVIS_OS_NAME" = "windows" ]; then choco install zip ; fi 32 | - if [ "$TRAVIS_OS_NAME" = "osx" ]; then rm dist/PySpy ; fi 33 | - (cd dist; zip -r "../PySpy-$TRAVIS_OS_NAME-$TRAVIS_TAG.zip" .) 34 | - ls 35 | - ls dist/ 36 | deploy: 37 | provider: releases 38 | api_key: $GITHUB_TOKEN 39 | file_glob: true 40 | file: 41 | - "PySpy-$TRAVIS_OS_NAME-$TRAVIS_TAG.zip" 42 | skip_cleanup: true 43 | on: 44 | tags: true -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 - White Russsian (https://github.com/WhiteRusssian) 4 | 5 | All rights reserved. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # !! This Tool is unmaintained and in a not working state. !! 4 | 5 | # PySpy - A simple EVE Online character intel tool using CCP's ESI API 6 | 7 |

8 | 9 | Current Version 10 | 11 | 12 | Number of releases downloaded 13 | 14 | 15 | Build Status 16 | 17 |

18 | 19 | **Download the latest release [here](https://github.com/Eve-PySpy/PySpy/releases/latest).** 20 | 21 | ## Background 22 | 23 | PySpy is a fast and simple character intel tool for [EVE Online](https://www.eveonline.com/). Within seconds, PySpy gathers useful information on character names from the in-game *local chat* window. 24 | 25 | PySpy connects to [CCP's ESI API](https://esi.evetech.net/ui/) and the 26 | [zKillboard API](https://github.com/zKillboard/zKillboard/wiki) and is available on Windows, macOS and Linux. 27 | 28 | In addition, PySpy uses a proprietary database which creates summary statistics for approximately 2.4 million EVE Online pilots, based on some 50 million killmails dating back to December 2007. This database is updated daily, shortly after CCP server downtime. 29 | 30 | If you enjoy using PySpy and would like to show your appreciation, please feel free to send ISK in-game to White Russsian (with 3 's'). Thank you. 31 | 32 | ## How to use PySpy 33 | 34 | 1. Open PySpy. 35 | 2. In your EVE client, select a list of characters and copy them to the clipboard (`CTRL+C` on Windows *or* `⌘+C` on macOS). 36 | 3. Wait until PySpy is done and inspect the results. 37 | 4. Double-click a name to open the respective zKillboard in your browser. zKillboard will open to the relevant page based on the column you have clicked on. (Or to the character page if you turn off advanced zkillboard linking) 38 | 39 | **Note**: PySpy will save its window location, size, column sizes, sorting order and transparency (slider on bottom right) and any other settings automatically and restore them the next time you launch it (settings will be reset whenever you update to a new version). If selected in the _View Menu_, PySpy will stay on top of the EVE client so long as the game runs in *window mode*. 40 | 41 | ## Information Provided by PySpy 42 | 43 | ### New Dark Mode 44 |

45 | PySpy in action 46 |

47 | 48 | ### Traditional Normal Mode 49 |

50 | PySpy in action 51 |

52 | 53 | * **Warning**: Displays reasons why a character might be highlighted 54 | * **Character**: Character name. 55 | * **Security**: Concord security status. 56 | * **Corporation**: Corporation of character. 57 | * **Alliance**: Alliance of character's Corporation, if any. 58 | * **Faction**: Faction of character, if any. 59 | * **Kills**: Total number of kills. 60 | * **Losses**: Total number of losses. 61 | * **Last Wk**: Number of kills over past 7 days. 62 | * **Solo**: Ratio of solo kills over total kills. 63 | * **BLOPS**: Number of Black Ops Battleships (BLOPS) killed. 64 | * **HICs**: Number of lost Heavy Interdiction Cruisers (HIC). 65 | * **Last Loss**: Days since last loss. 66 | * **Last Kill**: Days since last kill. 67 | * **Avg. Attackers**: Average number of attackers per kill. 68 | * **Covert Cyno**: Ratio of losses where a covert cyno was fitted to total losses. 69 | * **Regular Cyno**: Ratio of losses where a regular cyno was fitted to total losses. 70 | * **Last Covert Cyno**: Ship type of most recent loss where covert cyno was fitted. 71 | * **Last Regular Cyno**: Ship type of most recent loss where regular cyno was fitted. 72 | * **Abyssal Losses**: Number of ship losses in Abyssal space. 73 | 74 | **Current Limitations**: To avoid undue strain on zKillboard's API, PySpy will run the *Kills*, *Losses*, *Last Wk*, *Solo*, *BLOPS* and *HICs* analyses only for the first 100 characters in the list. 75 | 76 | ## Ignore Certain Entities 77 | 78 | PySpy allows you to specify a list of ignored characters, corporations and alliances. To add entities to that list, right click on a search result. You can remove entities from this list under _Options_->_Review Ignored Entities_. 79 | 80 | ## Ignore all Members of your NPSI Fleet 81 | 82 | For anyone using PySpy in not-purple-shoot-it (NPSI) fleets, you can tell PySpy to temporarily ignore your fleet mates by first running PySpy on all characters in your fleet chat and then selecting _Options_->_Set NPSI Ignore List_. Once the fleet is over, you can clear the list under _Options_->_Clear NPSI List_. Your custom ignore list described above will not be affected by this action. 83 | 84 | ## Highlighting 85 | 86 | PySpy allows you to specify a list of highlighted characters, corporations and alliances. 87 | These entities will be highlighted in a different color from the others. 88 | To add and remove entities to that list, right click on a search result. 89 | You can also review and remove entities from this list under _Options_->_Review Highlighted Entities_. 90 | 91 | Furthermore PySpy can also highlight a character if he uses Black Ops and Heavy Interdiction Cruisers or frequently has a cyno fitted. 92 | 93 | ## Installation 94 | 95 | You can download the latest release for your operating system [here](https://github.com/Eve-PySpy/PySpy/releases/latest). 96 | 97 | PySpy comes as a single-file executable both in Windows and macOS. On both platforms, you can run PySpy from any folder location you like. 98 | 99 | On Linux, you can run PySpy like any other Python3 script. PySpy was developed on Python 3.6.5 but should run on any other Python3 version, so long as you install the required packages listed in [requirements.txt](https://github.com/Eve-PySpy/PySpy/blob/master/requirements.txt). 100 | 101 | If you want to build PySpy into an executable yourself, then the pyinstaller spec file is provided, you will likely need to provide the api-ms-core dlls that python requires. details of this can be found [here](https://github.com/pyinstaller/pyinstaller/issues/4047#issuecomment-460869714). You will know you need them if pyinstaller complains about missing them when run. 102 | 103 | **Note**: PySpy automatically checks for updates on launch and will notify you if a new version is available. 104 | 105 | ## Uninstalling PySpy 106 | 107 | Delete the PySpy executable and remove the following files manually: 108 | 109 | * **Windows**: PySpy saves preference and log files in a folder called `PySpy` located at `%LocalAppData%`. 110 | * **macOS**: PySpy creates `pyspy.log` under `~/Library/Logs` and `pyspy.cfg` as well as `pyspy.pickle` under `~/Library/Preferences`. 111 | * **Linux**: PySpy creates `pyspy.log` under `~/Library/Logs` and `pyspy.cfg` as well as `pyspy.pickle` under `~/.config/pyspy`. 112 | 113 | ## Future Features 114 | 115 | Below is a non-exhaustive list of additional features I plan to add to PySpy as and when the ESI and zKillboard APIs support them: 116 | 117 | * **Standings**: Only show characters that are non-blue, i.e. neutral or hostile. 118 | * **Highlight New Pilots**: Highlight any pilots that have entered system since last PySpy run. 119 | * **Improved GUI**: The current GUI is very basic and while it works, I do appreciate that it is not ideal for people who cannot use it on a second screen but actually have to overlay it on-top of their EVE client. 120 | 121 | Please feel free to add a [feature request](https://github.com/Eve-PySpy/PySpy/issues/new?template=pyspy-feature-request.md) for any improvements you would like to see in future releases. 122 | 123 | ## Bug Reporting 124 | 125 | Despite extensive testing, you may encounter the odd bug. If so, please check if an existing [issue](https://github.com/WhiteRusssian/PySpy/issues) already describes your bug. If not, feel free to [create a new issue](https://github.com/WhiteRusssian/PySpy/issues/new?template=pyspy-bug-report.md) for your bug. 126 | 127 | ## Dependencies & Acknowledgements 128 | 129 | * PySpy is written in [Python 3](https://www.python.org/), licensed under [PSF's License Agreement](https://docs.python.org/3/license.html#psf-license-agreement-for-python-release). 130 | * For API connectivity, PySpy relies on [Requests](http://docs.python-requests.org/) (v2.19.1), licensed under the [Apache License, Version 2.0](http://docs.python-requests.org/en/master/user/intro/#requests-license). 131 | * Clipboard monitoring is implemented with the help of [pyperclip](https://github.com/asweigart/pyperclip) (v1.6.2), licensed under the [3-Clause BSD License](https://github.com/asweigart/pyperclip/blob/master/LICENSE.txt). 132 | * The GUI is powered by [wxPython](https://www.wxpython.org/) (v4.0.3), licensed under the [wxWindows Library Licence](https://wxpython.org/pages/license/index.html). 133 | * The Windows and macOS executables are built using [pyinstaller](https://www.pyinstaller.org/), licensed under [its own modified GPL license](https://raw.githubusercontent.com/pyinstaller/pyinstaller/develop/COPYING.txt). 134 | * PySpy's icon was created by Jojo Mendoza and is licensed under [Creative Commons (Attribution-Noncommercial 3.0 Unported)](https://creativecommons.org/licenses/by-nc/3.0/). It is available on [IconFinder](https://www.iconfinder.com/icons/1218719/cyber_hat_spy_undercover_user_icon). 135 | 136 | ## License 137 | 138 | PySpy is licensed under the [MIT](LICENSE.txt) License. 139 | 140 | ## CCP Copyright Notice 141 | 142 | EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide. All other trademarks are the property of their respective owners. EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf. All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the intellectual property relating to these trademarks are likewise the intellectual property of CCP hf. CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for any damage arising from the use of this website. 143 | 144 | ## Collection of Usage Statistics 145 | 146 | To help improve PySpy further, PySpy reports usage statistics comprising certain anonymous information such as the number of characters analysed, duration of each analysis, operating system, version of PySpy, and any active GUI features. For full disclosure, a randomly generated identifier is being sent with each data set to allow me to track how many people are actually using PySpy over any given period. If you would like to see for yourself what is being collected, have a look at the source code of module `reportstats.py`. 147 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v0.5.2 -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' This is the primary module responsible for launching a background 6 | thread that watches for changes in the clipboard and if it detects a 7 | list of strings that could be EVE Online character strings, sends them 8 | to the analyze.py module to gather specific information from CCP's ESI 9 | API and zKIllboard's API. This information then gets sent to the GUI for 10 | output. 11 | ''' 12 | # ********************************************************************** 13 | import logging 14 | import re 15 | import threading 16 | import time 17 | 18 | import wx 19 | import pyperclip 20 | 21 | import analyze 22 | import chkversion 23 | import config 24 | import gui 25 | import reportstats 26 | import statusmsg 27 | import db 28 | # cSpell Checker - Correct Words**************************************** 29 | # // cSpell:words russsian, ccp's, pyperclip, chkversion, clpbd, gui 30 | # ********************************************************************** 31 | Logger = logging.getLogger(__name__) 32 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 33 | 34 | 35 | def watch_clpbd(): 36 | valid = False 37 | recent_value = None 38 | while True: 39 | clipboard = pyperclip.paste() 40 | if clipboard != recent_value: 41 | char_names = clipboard.splitlines() 42 | for name in char_names: 43 | valid = check_name_validity(name) 44 | if valid is False: 45 | break 46 | if valid: 47 | statusmsg.push_status("Clipboard change detected...") 48 | recent_value = clipboard 49 | analyze_chars(clipboard.splitlines()) 50 | time.sleep(0.5) # Short sleep between loops to reduce CPU load 51 | 52 | 53 | def check_name_validity(char_name): 54 | if len(char_name) < 3: 55 | return False 56 | regex = r"[^ 'a-zA-Z0-9-]" # Valid EVE Online character names 57 | if re.search(regex, char_name): 58 | return False 59 | return True 60 | 61 | 62 | def analyze_chars(char_names): 63 | conn_mem, cur_mem = db.connect_memory_db() 64 | conn_dsk, cur_dsk = db.connect_persistent_db() 65 | start_time = time.time() 66 | wx.CallAfter(app.PySpy.grid.ClearGrid) 67 | try: 68 | outlist = analyze.main(char_names, conn_mem, cur_mem, conn_dsk, cur_dsk) 69 | duration = round(time.time() - start_time, 1) 70 | reportstats.ReportStats(outlist, duration).start() 71 | if outlist is not None: 72 | # Need to use keyword args as sortOutlist can also get called 73 | # by event handler which would pass event object as first argument. 74 | wx.CallAfter( 75 | app.PySpy.sortOutlist, 76 | outlist=outlist, 77 | duration=duration 78 | ) 79 | else: 80 | statusmsg.push_status( 81 | "No valid character names found. Please try again..." 82 | ) 83 | except Exception: 84 | Logger.error( 85 | "Failed to collect character information. Clipboard " 86 | "content was: " + str(char_names), exc_info=True 87 | ) 88 | 89 | 90 | app = gui.App(0) # Has to be defined before background thread starts. 91 | 92 | background_thread = threading.Thread( 93 | target=watch_clpbd, 94 | daemon=True 95 | ) 96 | background_thread.start() 97 | 98 | update_checker = threading.Thread( 99 | target=chkversion.chk_github_update, 100 | daemon=True 101 | ) 102 | update_checker.start() 103 | 104 | app.MainLoop() 105 | -------------------------------------------------------------------------------- /__main__.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | '''Cross-platform (MacOSX and Windows 10) Spec file for pyinstaller. 3 | Be sure to not use python 3.7 until pyinstaller supports it.''' 4 | # cSpell Checker - Correct Words**************************************** 5 | # // cSpell:words pyinstaller, pyspy, posix, icns, getcwd, datas, 6 | # // cSpell:words tkinter, noconsole 7 | # ********************************************************************** 8 | import os 9 | 10 | if os.name == "nt": 11 | ICON_FILE = os.path.join("assets", "pyspy.ico") 12 | elif os.name == "posix": 13 | ICON_FILE = os.path.join("assets", "pyspy.png") 14 | 15 | MAC_ICON = os.path.join("assets", "pyspy.icns") 16 | ABOUT_ICON = os.path.join("assets", "pyspy_mid.png") 17 | 18 | block_cipher = None 19 | 20 | a = Analysis( 21 | ["__main__.py"], 22 | pathex=[os.getcwd()], 23 | binaries=[], 24 | datas=[ 25 | (ICON_FILE, "."), 26 | (ABOUT_ICON, "."), 27 | ("VERSION", "."), 28 | ("LICENSE.txt", "."), 29 | ], 30 | hiddenimports=[], 31 | hookspath=[], 32 | runtime_hooks=[], 33 | excludes=["Tkinter"], 34 | win_no_prefer_redirects=False, 35 | win_private_assemblies=False, 36 | cipher=block_cipher 37 | ) 38 | 39 | pyz = PYZ( 40 | a.pure, 41 | a.zipped_data, 42 | cipher=block_cipher 43 | ) 44 | 45 | exe = EXE( 46 | pyz, 47 | a.scripts, 48 | a.binaries, 49 | a.zipfiles, 50 | a.datas, 51 | name="PySpy", 52 | icon=ICON_FILE, 53 | debug=False, 54 | strip=False, 55 | upx=True, 56 | runtime_tmpdir=None, 57 | console=False, 58 | noconsole=True, 59 | onefile=True, 60 | windowed=True 61 | ) 62 | 63 | app = BUNDLE( 64 | exe, 65 | name="PySpy.app", 66 | icon=MAC_ICON, 67 | bundle_identifier=None 68 | ) 69 | -------------------------------------------------------------------------------- /aboutdialog.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | '''The About Dialog for PySpy's GUI. OnAboutBox() gets called by the GUI 6 | module.''' 7 | # ********************************************************************** 8 | import logging 9 | import time 10 | 11 | import wx 12 | import wx.adv 13 | 14 | import __main__ 15 | import config 16 | # cSpell Checker - Correct Words**************************************** 17 | # // cSpell:words russsian, wxpython, ccp's 18 | # // cSpell:words 19 | # ********************************************************************** 20 | Logger = logging.getLogger(__name__) 21 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 22 | 23 | 24 | def showAboutBox(parent, event=None): 25 | # __main__.app.PySpy.ToggleWindowStyle(wx.STAY_ON_TOP) 26 | 27 | description = """ 28 | PySpy is an EVE Online character intel tool 29 | using CCP's ESI API and a daily updated proprietary 30 | database containing key statistics on approximately 31 | 2.4 million pilots. 32 | 33 | If you enjoy PySpy and want to show your appreciation 34 | to its author, you are welcome to send an ISK donation 35 | in-game to White Russsian (with 3 "s"). 36 | 37 | Thank you.""" 38 | 39 | try: 40 | with open(config.resource_path('LICENSE.txt'), 'r') as lic_file: 41 | license = lic_file.read() 42 | except: 43 | license = "PySpy is licensed under the MIT License." 44 | 45 | info = wx.adv.AboutDialogInfo() 46 | 47 | info.SetIcon(wx.Icon(config.ABOUT_ICON, wx.BITMAP_TYPE_PNG)) 48 | info.SetName("PySpy") 49 | info.SetVersion(config.CURRENT_VER) 50 | info.SetDescription(description) 51 | info.SetCopyright('(C) 2018 White Russsian') 52 | info.SetWebSite('https://github.com/Eve-PySpy/PySpy') 53 | info.SetLicence(license) 54 | 55 | wx.adv.AboutBox(info) 56 | -------------------------------------------------------------------------------- /analyze.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' The primary function is main(), which takes a set of EVE Online 6 | character names and gathers useful information from CCP's ESI API and 7 | zKIllboard's API, to be stored in a temporary in-memory SQLite3 database. 8 | ''' 9 | # ********************************************************************** 10 | import logging 11 | import json 12 | import threading 13 | import queue 14 | import time 15 | import datetime 16 | 17 | import apis 18 | import config 19 | import db 20 | import statusmsg 21 | # cSpell Checker - Correct Words**************************************** 22 | # // cSpell:words affil, zkill, blops, qsize, numid, russsian, ccp's 23 | # // cSpell:words records_added 24 | # ********************************************************************** 25 | Logger = logging.getLogger(__name__) 26 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 27 | 28 | 29 | def main(char_names, conn_mem, cur_mem, conn_dsk, cur_dsk): 30 | chars_found = get_char_ids(conn_mem, cur_mem, char_names) 31 | if chars_found > 0: 32 | # Run Pyspy remote database query in seprate thread 33 | tp = threading.Thread( 34 | target=get_character_intel(conn_mem, cur_mem), 35 | daemon=True 36 | ) 37 | tp.start() 38 | 39 | # Run zKill query in seprate thread 40 | char_ids_mem = cur_mem.execute( 41 | "SELECT char_id, last_update FROM characters ORDER BY char_name" 42 | ).fetchall() 43 | 44 | cache_max_age = datetime.datetime.now() - datetime.timedelta(seconds=config.CACHE_TIME) 45 | 46 | char_ids_dsk = cur_dsk.execute( 47 | "SELECT char_id, last_update FROM characters WHERE last_update > ? ORDER BY char_name", (cache_max_age,) 48 | ).fetchall() 49 | 50 | char_ids_mem_d = dict(char_ids_mem) 51 | char_ids_dsk_d = dict(char_ids_dsk) 52 | 53 | ids_mem = set(char_ids_mem_d.keys()) 54 | ids_dsk = set(char_ids_dsk_d.keys()) 55 | 56 | cache_hits = ids_mem & ids_dsk # Intersection of what we want and what we already have 57 | cache_miss = ids_mem - cache_hits 58 | 59 | Logger.debug("Cache Hits - {}".format(len(cache_hits))) 60 | Logger.debug("Cache Miss - {}".format(len(cache_miss))) 61 | 62 | zkill_req = [r for r in char_ids_mem if r[0] in cache_miss] 63 | 64 | q_main = queue.Queue() 65 | tz = zKillStats(zkill_req, q_main) 66 | tz.start() 67 | 68 | get_char_affiliations(conn_mem, cur_mem) 69 | get_affil_names(conn_mem, cur_mem) 70 | 71 | # Join zKill thread 72 | tz.join() 73 | update_datetime = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 74 | zkill_stats = q_main.get() 75 | for entry in zkill_stats: 76 | entry.insert(-1, update_datetime) 77 | query_string = ( 78 | '''UPDATE characters SET kills=?, blops_kills=?, hic_losses=?, 79 | week_kills=?, losses=?, solo_ratio=?, sec_status=?, last_update=? 80 | WHERE char_id=?''' 81 | ) 82 | 83 | cache_stats = [] 84 | for char_id in cache_hits: 85 | # kills, blops_kills, hic_losses, week_kills, losses, solo_ratio, sec_status, id 86 | cache_query = '''SELECT kills, blops_kills, hic_losses, week_kills, losses, solo_ratio, 87 | sec_status, last_update, char_id FROM characters WHERE char_id = ?''' 88 | stat = tuple(cur_dsk.execute(cache_query, (char_id,)).fetchone()) #SHOULD ONLY BE ONE ENTRY!!! 89 | cache_stats.append(stat) 90 | 91 | cache_char_query_string = ( 92 | '''INSERT OR REPLACE INTO characters (char_id, char_name) VALUES (?, ?)''' 93 | ) 94 | 95 | db.write_many_to_db(conn_dsk, cur_dsk, cache_char_query_string, zkill_req) 96 | db.write_many_to_db(conn_dsk, cur_dsk, query_string, zkill_stats) 97 | 98 | db.write_many_to_db(conn_mem, cur_mem, query_string, zkill_stats) 99 | db.write_many_to_db(conn_mem, cur_mem, query_string, cache_stats) 100 | 101 | # Join Pyspy remote database thread 102 | tp.join() 103 | output = output_list(cur_mem) 104 | conn_mem.close() 105 | return output 106 | else: 107 | return 108 | 109 | 110 | def get_char_ids(conn, cur, char_names): 111 | char_names = json.dumps(char_names[0:config.MAX_NAMES]) # apis max char is 1000 112 | statusmsg.push_status("Resolving character names to IDs...") 113 | try: 114 | characters = apis.post_req_ccp("universe/ids/", char_names) 115 | characters = characters['characters'] 116 | except: 117 | return 0 118 | records = () 119 | for r in characters: 120 | records = records + ((r["id"], r["name"]),) 121 | query_string = ( 122 | '''INSERT OR REPLACE INTO characters (char_id, char_name) VALUES (?, ?)''' 123 | ) 124 | records_added = db.write_many_to_db(conn, cur, query_string, records) 125 | return records_added 126 | 127 | 128 | def get_char_affiliations(conn, cur): 129 | char_ids = cur.execute("SELECT char_id FROM characters").fetchall() 130 | char_ids = json.dumps(tuple([r[0] for r in char_ids])) 131 | statusmsg.push_status("Retrieving character affiliation IDs...") 132 | try: 133 | affiliations = apis.post_req_ccp("characters/affiliation/", char_ids) 134 | except: 135 | Logger.info("Failed to obtain character affiliations.", exc_info=True) 136 | raise Exception 137 | 138 | records = () 139 | for r in affiliations: 140 | corp_id = r["corporation_id"] 141 | alliance_id = r["alliance_id"] if "alliance_id" in r else 0 142 | faction_id = r["faction_id"] if "faction_id" in r else 0 143 | char_id = r["character_id"] 144 | records = records + ((corp_id, alliance_id, faction_id, char_id), ) 145 | 146 | query_string = ( 147 | '''UPDATE characters SET corp_id=?, alliance_id=?, faction_id=? 148 | WHERE char_id=?''' 149 | ) 150 | db.write_many_to_db(conn, cur, query_string, records) 151 | 152 | 153 | def get_affil_names(conn, cur): 154 | # use select distinct to get corp and alliance ids and reslove them 155 | alliance_ids = cur.execute( 156 | '''SELECT DISTINCT alliance_id FROM characters 157 | WHERE alliance_id IS NOT 0''' 158 | ).fetchall() 159 | corp_ids = cur.execute( 160 | "SELECT DISTINCT corp_id FROM characters WHERE corp_id IS NOT 0" 161 | ).fetchall() 162 | 163 | ids = alliance_ids + corp_ids 164 | ids = json.dumps(tuple([r[0] for r in ids])) 165 | 166 | statusmsg.push_status("Obtaining corporation and alliance names and zKillboard data...") 167 | try: 168 | names = apis.post_req_ccp("universe/names/", ids) 169 | except: 170 | Logger.info("Failed request corporation and alliance names.", 171 | exc_info=True) 172 | raise Exception 173 | 174 | alliances, corporations = (), () 175 | for r in names: 176 | if r["category"] == "alliance": 177 | alliances = alliances + ((r["id"], r["name"]),) 178 | elif r["category"] == "corporation": 179 | corporations = corporations + ((r["id"], r["name"]),) 180 | if alliances: 181 | query_string = ('''INSERT INTO alliances (id, name) VALUES (?, ?)''') 182 | db.write_many_to_db(conn, cur, query_string, alliances) 183 | if corporations: 184 | query_string = ('''INSERT INTO corporations (id, name) VALUES (?, ?)''') 185 | db.write_many_to_db(conn, cur, query_string, corporations) 186 | 187 | 188 | class zKillStats(threading.Thread): 189 | 190 | def __init__(self, char_ids, q_main): 191 | super(zKillStats, self).__init__() 192 | self.daemon = True 193 | self._char_ids = char_ids 194 | self._q_main = q_main 195 | 196 | def run(self): 197 | count = 0 198 | max = config.ZKILL_CALLS 199 | threads = [] 200 | q_sub = queue.Queue() 201 | for id in self._char_ids: 202 | t = apis.Query_zKill(id[0], q_sub) 203 | threads.append(t) 204 | t.start() 205 | count += 1 206 | time.sleep(config.ZKILL_DELAY) 207 | if count >= max: 208 | break 209 | for t in threads: 210 | t.join(5) 211 | zkill_stats = [] 212 | while q_sub.qsize(): 213 | # Run through each queue item and prepare response list. 214 | s = q_sub.get() 215 | kills = str(s[0]) 216 | blops_kills = str(s[1]) 217 | hic_losses = str(s[2]) 218 | week_kills = str(s[3]) 219 | losses = str(s[4]) 220 | solo_ratio = str(s[5]) 221 | sec_status = str(s[6]) 222 | id = str(s[7]) 223 | zkill_stats.append([ 224 | kills, blops_kills, hic_losses, week_kills, losses, solo_ratio, 225 | sec_status, id 226 | ]) 227 | self._q_main.put(zkill_stats) 228 | return 229 | 230 | 231 | def get_character_intel(conn, cur): 232 | ''' 233 | Adds certain character killboard statistics derived from PySpy's 234 | proprietary database to the local SQLite3 database. 235 | 236 | :param `conn`: SQLite3 connection object. 237 | :param `cur`: SQLite3 cursor object. 238 | ''' 239 | char_ids = cur.execute("SELECT char_id FROM characters").fetchall() 240 | char_intel = apis.post_proprietary_db(char_ids) 241 | records = () 242 | for r in char_intel: 243 | char_id = r["character_id"] 244 | last_loss_date = r["last_loss_date"] if r["last_loss_date"] is not None else 0 245 | last_kill_date = r["last_kill_date"] if r["last_kill_date"] is not None else 0 246 | avg_attackers = r["avg_attackers"] if r["avg_attackers"] is not None else 0 247 | covert_prob = r["covert_prob"] if r["covert_prob"] is not None else 0 248 | normal_prob = r["normal_prob"] if r["normal_prob"] is not None else 0 249 | last_cov_ship = r["last_cov_ship"] if r["last_cov_ship"] is not None else 0 250 | last_norm_ship = r["last_norm_ship"] if r["last_norm_ship"] is not None else 0 251 | abyssal_losses = r["abyssal_losses"] if r["abyssal_losses"] is not None else 0 252 | 253 | records = records + (( 254 | last_loss_date, last_kill_date, avg_attackers, covert_prob, 255 | normal_prob, last_cov_ship, last_norm_ship, abyssal_losses, char_id 256 | ), ) 257 | 258 | query_string = ( 259 | '''UPDATE characters SET last_loss_date=?, last_kill_date=?, 260 | avg_attackers=?, covert_prob=?, normal_prob=?, 261 | last_cov_ship=?, last_norm_ship=?, abyssal_losses=? 262 | WHERE char_id=?''' 263 | ) 264 | db.write_many_to_db(conn, cur, query_string, records) 265 | 266 | 267 | def output_list(cur): 268 | query_string = ( 269 | '''SELECT 270 | ch.char_id, ch.faction_id, ch.char_name, co.id, co.name, al.id, 271 | al.name, fa.name, ac.numid, ch.week_kills, ch.kills, ch.blops_kills, 272 | ch.hic_losses, ch.losses, ch.solo_ratio, ch.sec_status, 273 | ch.last_loss_date, ch.last_kill_date, 274 | ch.avg_attackers, ch.covert_prob, ch.normal_prob, 275 | IFNULL(cs.name,'-'), IFNULL(ns.name,'-'), ch.abyssal_losses 276 | FROM characters AS ch 277 | LEFT JOIN alliances AS al ON ch.alliance_id = al.id 278 | LEFT JOIN corporations AS co ON ch.corp_id = co.id 279 | LEFT JOIN factions AS fa ON ch.faction_id = fa.id 280 | LEFT JOIN (SELECT alliance_id, COUNT(alliance_id) AS numid FROM characters GROUP BY alliance_id) 281 | AS ac ON ch.alliance_id = ac.alliance_id 282 | LEFT JOIN ships AS cs ON ch.last_cov_ship = cs.id 283 | LEFT JOIN ships AS ns ON ch.last_norm_ship = ns.id 284 | ORDER BY ch.char_name''' 285 | ) 286 | return cur.execute(query_string).fetchall() 287 | -------------------------------------------------------------------------------- /apis.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' This module provides connectivity to CCP's ESI API, to zKillboard's 6 | API and to PySpy's own proprietary RESTful API. 7 | ''' 8 | # ********************************************************************** 9 | import json 10 | import logging 11 | import threading 12 | import time 13 | 14 | import requests 15 | 16 | import config 17 | import statusmsg 18 | # cSpell Checker - Correct Words**************************************** 19 | # // cSpell:words wrusssian, ZKILL, gmail, blops, toon, HICs, russsian, 20 | # // cSpell:words ccp's, activepvp 21 | # ********************************************************************** 22 | Logger = logging.getLogger(__name__) 23 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 24 | 25 | 26 | # ESI Status 27 | # https://esi.evetech.net/ui/?version=meta#/Meta/get_status 28 | 29 | def post_req_ccp(esi_path, json_data): 30 | url = "https://esi.evetech.net/latest/" + esi_path + \ 31 | "?datasource=tranquility" 32 | try: 33 | r = requests.post(url, json_data) 34 | except requests.exceptions.ConnectionError: 35 | Logger.info("No network connection.", exc_info=True) 36 | statusmsg.push_status( 37 | "NETWORK ERROR: Check your internet connection and firewall settings." 38 | ) 39 | time.sleep(5) 40 | return "network_error" 41 | if r.status_code != 200: 42 | server_msg = json.loads(r.text)["error"] 43 | Logger.info( 44 | "CCP Servers at (" + esi_path + ") returned error code: " + 45 | str(r.status_code) + ", saying: " + server_msg, exc_info=True 46 | ) 47 | statusmsg.push_status( 48 | "CCP SERVER ERROR: " + str(r.status_code) + " (" + server_msg + ")" 49 | ) 50 | return "server_error" 51 | return r.json() 52 | 53 | 54 | class Query_zKill(threading.Thread): 55 | # Run in a separate thread to query certain kill board statistics 56 | # from zKillboard's API and return the values via a queue object. 57 | def __init__(self, char_id, q): 58 | super(Query_zKill, self).__init__() 59 | self.daemon = True 60 | self._char_id = char_id 61 | self._queue = q 62 | 63 | def run(self): 64 | url = ( 65 | "https://zkillboard.com/api/stats/characterID/" + 66 | str(self._char_id) + "/" 67 | ) 68 | headers = { 69 | "Accept-Encoding": "gzip", 70 | "User-Agent": "PySpy, Author: White Russsian, https://github.com/WhiteRusssian/PySpy" 71 | } 72 | try: 73 | r = requests.get(url, headers=headers) 74 | except requests.exceptions.ConnectionError: 75 | Logger.info("No network connection.", exc_info=True) 76 | statusmsg.push_status( 77 | '''NETWORK ERROR: Check your internet connection 78 | and firewall settings.''' 79 | ) 80 | time.sleep(5) 81 | return "network error" 82 | if r.status_code != 200: 83 | server_msg = "N/A" 84 | try: 85 | server_msg = json.loads(r.text)["error"] 86 | except: 87 | pass 88 | Logger.info( 89 | "zKillboard server returned error for character ID " + 90 | str(self._char_id) + ". Error code: " + str(r.status_code), 91 | exc_info=True 92 | ) 93 | statusmsg.push_status( 94 | "ZKILL SERVER ERROR: " + str(r.status_code) + " (" + server_msg + ")" 95 | ) 96 | return "server error" 97 | try: 98 | r = r.json() 99 | except AttributeError: 100 | kills = 0 101 | blops_kills = 0 102 | hic_losses = 0 103 | self._queue.put([kills, blops_kills, self._char_id]) 104 | return 105 | 106 | try: 107 | # Number of total kills 108 | kills = r["shipsDestroyed"] 109 | except (KeyError, TypeError): 110 | kills = 0 111 | 112 | try: 113 | # Number of BLOPS killed 114 | blops_kills = r["groups"]["898"]["shipsDestroyed"] 115 | except (KeyError, TypeError): 116 | blops_kills = 0 117 | 118 | try: 119 | # Number of HICs lost 120 | hic_losses = r["groups"]["894"]["shipsLost"] 121 | except (KeyError, TypeError): 122 | hic_losses = 0 123 | 124 | try: 125 | # Kills over past 7 days 126 | week_kills = r["activepvp"]["kills"]["count"] 127 | except (KeyError, TypeError): 128 | week_kills = 0 129 | 130 | try: 131 | # Number of total losses 132 | losses = r["shipsLost"] 133 | except (KeyError, TypeError): 134 | losses = 0 135 | 136 | try: 137 | # Ratio of solo kills to total kills 138 | solo_ratio = int(r["soloKills"]) / int(r["shipsDestroyed"]) 139 | except (KeyError, TypeError): 140 | solo_ratio = 0 141 | 142 | try: 143 | # Security status 144 | sec_status = r["info"]["secStatus"] 145 | except (KeyError, TypeError): 146 | sec_status = 0 147 | 148 | self._queue.put( 149 | [kills, blops_kills, hic_losses, week_kills, losses, solo_ratio, 150 | sec_status, self._char_id] 151 | ) 152 | return 153 | 154 | 155 | def post_proprietary_db(character_ids): 156 | ''' 157 | Query PySpy's proprietary kill database for the character ids 158 | provided as a list or tuple of integers. Returns a JSON containing 159 | one line per character id. 160 | 161 | :param `character_ids`: List or tuple of character ids as integers. 162 | :return: JSON dictionary containing certain statistics for each id. 163 | ''' 164 | url = "http://pyspy.pythonanywhere.com" + "/character_intel/" + "v1/" 165 | headers = { 166 | "Accept-Encoding": "gzip", 167 | "User-Agent": "PySpy, Author: White Russsian, https://github.com/WhiteRusssian/PySpy" 168 | } 169 | # Character_ids is a list of tuples, which needs to be converted to dict 170 | # with list as value. 171 | character_ids = {"character_ids": character_ids} 172 | try: 173 | r = requests.post(url, headers=headers, json=character_ids) 174 | except requests.exceptions.ConnectionError: 175 | Logger.info("No network connection.", exc_info=True) 176 | statusmsg.push_status( 177 | "NETWORK ERROR: Check your internet connection and firewall settings." 178 | ) 179 | time.sleep(5) 180 | return "network_error" 181 | if r.status_code != 200: 182 | server_msg = json.loads(r.text)["error"] 183 | Logger.info( 184 | "PySpy server returned error code: " + 185 | str(r.status_code) + ", saying: " + server_msg, exc_info=True 186 | ) 187 | statusmsg.push_status( 188 | "PYSPY SERVER ERROR: " + str(r.status_code) + " (" + server_msg + ")" 189 | ) 190 | return "server_error" 191 | return r.json() 192 | 193 | 194 | def get_ship_data(): 195 | ''' 196 | Produces a list of ship id and ship name pairs for each ship in EVE 197 | Online, using ESI's universe/names endpoint. 198 | 199 | :return: List of lists containing ship ids and related ship names. 200 | ''' 201 | all_ship_ids = get_all_ship_ids() 202 | if not isinstance(all_ship_ids, (list, tuple)) or len(all_ship_ids) < 1: 203 | Logger.error("[get_ship_data] No valid ship ids provided.", exc_info=True) 204 | return 205 | 206 | url = "https://esi.evetech.net/v2/universe/names/?datasource=tranquility" 207 | json_data = json.dumps(all_ship_ids) 208 | try: 209 | r = requests.post(url, json_data) 210 | except requests.exceptions.ConnectionError: 211 | Logger.error("[get_ship_data] No network connection.", exc_info=True) 212 | return "network_error" 213 | if r.status_code != 200: 214 | server_msg = json.loads(r.text)["error"] 215 | Logger.error( 216 | "[get_ship_data] CCP Servers returned error code: " + 217 | str(r.status_code) + ", saying: " + server_msg, exc_info=True 218 | ) 219 | return "server_error" 220 | ship_data = list(map(lambda r: [r['id'], r['name']], r.json())) 221 | return ship_data 222 | 223 | 224 | def get_all_ship_ids(): 225 | ''' 226 | Uses ESI's insurance/prices endpoint to get all available ship ids. 227 | 228 | :return: List of ship ids as integers. 229 | ''' 230 | url = "https://esi.evetech.net/v1/insurance/prices/?datasource=tranquility" 231 | 232 | try: 233 | r = requests.get(url) 234 | except requests.exceptions.ConnectionError: 235 | Logger.error("[get_ship_ids] No network connection.", exc_info=True) 236 | return "network_error" 237 | if r.status_code != 200: 238 | server_msg = json.loads(r.text)["error"] 239 | Logger.error( 240 | "[get_ship_ids] CCP Servers at returned error code: " + 241 | str(r.status_code) + ", saying: " + server_msg, exc_info=True 242 | ) 243 | return "server_error" 244 | 245 | ship_ids = list(map(lambda r: str(r['type_id']), r.json())) 246 | Logger.info("[get_ship_ids] Number of ship ids found: " + str(len(ship_ids))) 247 | return ship_ids -------------------------------------------------------------------------------- /assets/cov_cyno_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/cov_cyno_64.png -------------------------------------------------------------------------------- /assets/hic_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/hic_64.png -------------------------------------------------------------------------------- /assets/norm_cyno_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/norm_cyno_64.png -------------------------------------------------------------------------------- /assets/pyspy.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy.icns -------------------------------------------------------------------------------- /assets/pyspy.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy.ico -------------------------------------------------------------------------------- /assets/pyspy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy.png -------------------------------------------------------------------------------- /assets/pyspy_mid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy_mid.png -------------------------------------------------------------------------------- /assets/pyspy_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/pyspy_small.png -------------------------------------------------------------------------------- /assets/v0.2_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.2_screenshot.png -------------------------------------------------------------------------------- /assets/v0.3_dark_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.3_dark_screenshot.png -------------------------------------------------------------------------------- /assets/v0.3_light_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.3_light_screenshot.png -------------------------------------------------------------------------------- /assets/v0.4_dark_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.4_dark_screenshot.png -------------------------------------------------------------------------------- /assets/v0.4_light_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eve-PySpy/PySpy/fc75df0fd9c1e8580a9383c3dc86f34a2ead5634/assets/v0.4_light_screenshot.png -------------------------------------------------------------------------------- /chkversion.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' Checks if there is a later version of PySpy available on GitHub.''' 6 | # ********************************************************************** 7 | import logging.config 8 | import logging 9 | import os 10 | import sys 11 | import datetime 12 | 13 | import requests 14 | import wx 15 | 16 | import __main__ 17 | import config 18 | # cSpell Checker - Correct Words**************************************** 19 | # // cSpell:words russsian 20 | # ********************************************************************** 21 | Logger = logging.getLogger(__name__) 22 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 23 | CURRENT_VER = config.CURRENT_VER 24 | 25 | 26 | def chk_github_update(): 27 | last_check = config.OPTIONS_OBJECT.Get("last_update_check", 0) 28 | if last_check == 0 or last_check < datetime.date.today(): 29 | # Get latest version available on GitHub 30 | GIT_URL = "https://api.github.com/repos/Eve-PySpy/PySpy/releases/latest" 31 | try: 32 | # verify=False to avoid certificate errors. This is not critical. 33 | latest_ver = requests.get(GIT_URL, verify=False).json()["tag_name"] 34 | Logger.info( 35 | "You are running " + CURRENT_VER + " and " + 36 | latest_ver + " is the latest version available on GitHub." 37 | ) 38 | config.OPTIONS_OBJECT.Set("last_update_check", datetime.date.today()) 39 | if latest_ver != CURRENT_VER: 40 | wx.CallAfter(__main__.app.PySpy.updateAlert, latest_ver, CURRENT_VER) 41 | except: 42 | Logger.info("Could not check GitHub for potential available updates.") 43 | 44 | 45 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' Define a few paths and constants used throughout other modules.''' 6 | # ********************************************************************** 7 | import logging.config 8 | import logging 9 | import os 10 | import platform 11 | import sys 12 | import uuid 13 | 14 | import requests 15 | import wx # required for colour codes in DARK_MODE 16 | 17 | import optstore 18 | # cSpell Checker - Correct Words**************************************** 19 | # // cSpell:words MEIPASS, datefmt, russsian, pyinstaller, posix, pyspy 20 | # // cSpell:words zkill, amarr, caldari, gallente, minmatar, isfile 21 | # ********************************************************************** 22 | Logger = logging.getLogger(__name__) 23 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 24 | 25 | 26 | # Location of packaged resource files when running pyinstaller --onefile 27 | def resource_path(relative_path): 28 | """ Get absolute path to resource, works for dev and for PyInstaller """ 29 | try: 30 | # PyInstaller creates a temp folder and stores path in _MEIPASS 31 | base_path = sys._MEIPASS 32 | except Exception: 33 | base_path = os.path.dirname(__file__) 34 | 35 | return os.path.join(base_path, relative_path) 36 | 37 | 38 | # If application is frozen executable 39 | if getattr(sys, 'frozen', False): 40 | ABOUT_ICON = resource_path("pyspy_mid.png") 41 | application_path = os.path.dirname(sys.executable) 42 | if os.name == "posix": 43 | PREF_PATH = os.path.expanduser("~/Library/Preferences") 44 | LOG_PATH = os.path.expanduser("~/Library/Logs") 45 | ICON_FILE = resource_path("pyspy.png") 46 | 47 | elif os.name == "nt": 48 | local_path = os.path.join(os.path.expandvars("%LocalAppData%"), "PySpy") 49 | if not os.path.exists(local_path): 50 | os.makedirs(local_path) 51 | PREF_PATH = local_path 52 | LOG_PATH = local_path 53 | ICON_FILE = resource_path("pyspy.ico") 54 | # If application is run as script 55 | elif __file__: 56 | ABOUT_ICON = resource_path("assets/pyspy_mid.png") 57 | application_path = os.path.dirname(__file__) 58 | if platform.system() == "Linux": 59 | PREF_PATH = os.path.expanduser("~/.config/pyspy") 60 | else: 61 | PREF_PATH = os.path.join(application_path, "tmp") 62 | if not os.path.exists(PREF_PATH): 63 | os.makedirs(PREF_PATH) 64 | LOG_PATH = PREF_PATH 65 | if os.name == "posix": 66 | ICON_FILE = resource_path("assets/pyspy.png") 67 | elif os.name == "nt": 68 | ICON_FILE = resource_path("assets/pyspy.ico") 69 | 70 | LOG_FILE = os.path.join(LOG_PATH, "pyspy.log") 71 | GUI_CFG_FILE = os.path.join(PREF_PATH, "pyspy.cfg") 72 | OPTIONS_FILE = os.path.join(PREF_PATH, "pyspy.pickle") 73 | DB_FILE = os.path.join(PREF_PATH, "pyspy.sqlite3") 74 | 75 | # Persisten options object 76 | OPTIONS_OBJECT = optstore.PersistentOptions(OPTIONS_FILE) 77 | 78 | # Read current version from VERSION file 79 | with open(resource_path('VERSION'), 'r') as ver_file: 80 | CURRENT_VER = ver_file.read().replace('\n', '') 81 | 82 | # Clean up old GUI_CFG_FILES and OPTIONS_OBJECT keys 83 | if os.path.isfile(GUI_CFG_FILE) and not os.path.isfile(OPTIONS_FILE): 84 | try: 85 | os.remove(GUI_CFG_FILE) 86 | except: 87 | pass 88 | if OPTIONS_OBJECT.Get("version", 0) != CURRENT_VER: 89 | print("Config file erased.") 90 | try: 91 | os.remove(GUI_CFG_FILE) 92 | except: 93 | pass 94 | for key in OPTIONS_OBJECT.ListKeys(): 95 | if key != "uuid": 96 | OPTIONS_OBJECT.Del(key) 97 | 98 | # Unique identifier for usage statistics reporting 99 | if OPTIONS_OBJECT.Get("uuid", "not set") == "not set": 100 | OPTIONS_OBJECT.Set("uuid", str(uuid.uuid4())) 101 | 102 | # Store version information 103 | OPTIONS_OBJECT.Set("version", CURRENT_VER) 104 | 105 | # Various constants 106 | MAX_NAMES = 500 # The max number of char names to be processed 107 | ZKILL_DELAY = 1 # API rate limit is 10/second, pushing it a little... 108 | ZKILL_CALLS = 100 109 | GUI_TITLE = "PySpy " + CURRENT_VER 110 | FONT_SCALE_MIN = 7 # 7 equates to 70% 111 | FONT_SCALE_MAX = 13 112 | MAX_SHIP_DATA_AGE = 7 # The maximum age of ship data (used in db.prepare_ship_data) 113 | CYNO_HL_PERCENTAGE = 0.05 # The minimum cover / normal cyno probability for highlighting 114 | CACHE_TIME = 43200 # Seconds for which zkill requests are cached 115 | 116 | # Colour Scheme 117 | 118 | DARK_MODE = { 119 | "BG": wx.Colour(0, 0, 0), 120 | "TXT": wx.Colour(247, 160, 55), # Yellow 121 | "LNE": wx.Colour(15, 15, 15), 122 | "LBL": wx.Colour(160, 160, 160), 123 | "HL1": wx.Colour(237, 72, 59), # Red 124 | "HL2": wx.Colour(62, 157, 250), # Blue 125 | "HL3": wx.Colour(237, 47, 218) # Pink 126 | } 127 | 128 | NORMAL_MODE = { 129 | "BG": wx.Colour(-1, -1, -1), 130 | "TXT": wx.Colour(45, 45, 45), 131 | "LNE": wx.Colour(240, 240, 240), 132 | "LBL": wx.Colour(32, 32, 32), 133 | "HL1": wx.Colour(187, 55, 46), 134 | "HL2": wx.Colour(38, 104, 166), 135 | "HL3": wx.Colour(237, 47, 218) 136 | } 137 | 138 | # Note, Amarr and Caldari are allied and have IDs ending on uneven integers. 139 | # Likewise, Gallente and Minmatar, also allied, have even IDs. 140 | # We will use this to block certain faction alliances. 141 | FACTION_IDS = ( 142 | (("500001", "Caldari"), ) + 143 | (("500002", "Minmatar"), ) + 144 | (("500003", "Amarr"), ) + 145 | (("500004", "Gallente"), ) 146 | ) 147 | IGNORED_FACTIONS = OPTIONS_OBJECT.Get("IgnoredFactions", 0) 148 | 149 | # Logging setup 150 | ''' For each module that requires logging, make sure to import modules 151 | logging and this config. Then get a new logger at the beginning 152 | of the module like this: "Logger = logging.getLogger(__name__)" and 153 | produce log messages like this: "Logger.error("text", exc_info=True)" 154 | ''' 155 | LOG_DETAIL = 'DEBUG' 156 | 157 | LOG_DICT = { 158 | 'version': 1, 159 | 'disable_existing_loggers': True, 160 | 'formatters': { 161 | 'standard': { 162 | 'format': '%(asctime)s [%(levelname)s] (%(name)s): %(message)s', 163 | 'datefmt': '%d-%b-%Y %I:%M:%S %p' 164 | }, 165 | }, 166 | 'handlers': { 167 | 'stream_handler': { 168 | 'level': 'DEBUG', 169 | 'formatter': 'standard', 170 | 'class': 'logging.StreamHandler', 171 | }, 172 | 'file_handler': { 173 | 'level': 'DEBUG', 174 | 'filename': LOG_FILE, 175 | 'class': 'logging.FileHandler', 176 | 'formatter': 'standard' 177 | }, 178 | 'timed_rotating_file_handler': { 179 | 'level': 'DEBUG', 180 | 'filename': LOG_FILE, 181 | 'class': 'logging.handlers.TimedRotatingFileHandler', 182 | 'when': 'D', 183 | 'interval': 7, # Log file rolling over every week 184 | 'backupCount': 1, 185 | 'formatter': 'standard' 186 | }, 187 | }, 188 | 'loggers': { 189 | '': { 190 | 'handlers': ['timed_rotating_file_handler', 'stream_handler'], 191 | 'level': 'DEBUG', 192 | 'propagate': True 193 | }, 194 | } 195 | } 196 | logging.config.dictConfig(LOG_DICT) 197 | -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | '''Establishes an in memory SQLite3 database and creates a few tables as 6 | well as provides a function to write records to the database.''' 7 | # ********************************************************************** 8 | import datetime 9 | import logging 10 | import sqlite3 11 | 12 | import config 13 | import apis 14 | # cSpell Checker - Correct Words**************************************** 15 | # // cSpell:words wrusssian, sqlite, blops, russsian 16 | # ********************************************************************** 17 | Logger = logging.getLogger(__name__) 18 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 19 | 20 | 21 | def connect_memory_db(): 22 | ''' 23 | Create in memory database 24 | 25 | @returns: connection and cursor objects as conn and cur 26 | ''' 27 | conn = sqlite3.connect(":memory:") 28 | conn.isolation_level = None 29 | cur = conn.cursor() 30 | cur.execute("PRAGMA journal_mode = TRUNCATE") 31 | prepare_tables(conn, cur) 32 | prepare_ship_data(conn, cur) 33 | return conn, cur 34 | 35 | def connect_persistent_db(): 36 | ''' 37 | Create on disk database 38 | 39 | @returns: connection and cursor objects as conn and cur 40 | ''' 41 | Logger.info("Connecting to persistent DB - {}".format(config.DB_FILE)) 42 | conn = sqlite3.connect(config.DB_FILE) 43 | cur = conn.cursor() 44 | cur.execute("PRAGMA journal_mode = TRUNCATE") 45 | prepare_tables(conn, cur) 46 | prepare_ship_data(conn, cur) 47 | return conn, cur 48 | 49 | def prepare_tables(conn, cur): 50 | ''' 51 | Create a few tables, unless they already exist. Do not close the 52 | connection as it will continue to be used by the calling 53 | function. 54 | ''' 55 | cur.execute( 56 | '''CREATE TABLE IF NOT EXISTS characters (char_name TEXT UNIQUE , char_id INT PRIMARY KEY , 57 | corp_id INT, alliance_id INT, faction_id INT, kills INT, 58 | blops_kills INT, hic_losses INT, week_kills INT, losses INT, 59 | solo_ratio NUMERIC, sec_status NUMERIC, last_loss_date INT, 60 | last_kill_date INT, avg_attackers NUMERIC, covert_prob NUMERIC, 61 | normal_prob NUMERIC, last_cov_ship INT, last_norm_ship INT, 62 | abyssal_losses INT, last_update TEXT)''' 63 | ) 64 | cur.execute( 65 | '''CREATE TABLE IF NOT EXISTS corporations (id INT PRIMARY KEY, name TEXT)''' 66 | ) 67 | cur.execute( 68 | '''CREATE TABLE IF NOT EXISTS alliances (id INT PRIMARY KEY, name TEXT)''' 69 | ) 70 | cur.execute( 71 | '''CREATE TABLE IF NOT EXISTS factions (id INT PRIMARY KEY, name TEXT)''' 72 | ) 73 | cur.execute( 74 | '''CREATE TABLE IF NOT EXISTS ships (id INT PRIMARY KEY, name TEXT)''' 75 | ) 76 | # Populate this table with the 4 faction warfare factions 77 | cur.executemany( 78 | '''INSERT OR REPLACE INTO factions (id, name) VALUES (?, ?)''', 79 | config.FACTION_IDS 80 | ) 81 | conn.commit() 82 | 83 | 84 | def prepare_ship_data(conn, cur): 85 | ''' 86 | Download all ship ids and names from ESI and save in OPTIONS_OBJECT. 87 | ''' 88 | ship_data_date = config.OPTIONS_OBJECT.Get("ship_data_date", 0) 89 | max_age = config.MAX_SHIP_DATA_AGE 90 | max_date = datetime.date.today() - datetime.timedelta(days=max_age) 91 | if ship_data_date == 0 or ship_data_date < max_date: 92 | config.OPTIONS_OBJECT.Set("ship_data", apis.get_ship_data()) 93 | config.OPTIONS_OBJECT.Set("ship_data_date", datetime.date.today()) 94 | # Populate ships table with ids and names for all ships in game 95 | cur.executemany( 96 | '''INSERT OR REPLACE INTO ships (id, name) VALUES (?, ?)''', 97 | config.OPTIONS_OBJECT.Get("ship_data", 0) 98 | ) 99 | conn.commit() 100 | 101 | 102 | def write_many_to_db(conn, cur, query_string, records, keepalive=True): 103 | ''' 104 | Take a database connection and write records to it. Afterwards, 105 | leave the connection alive, unless keepalive=False and return the 106 | number of records added to the database. 107 | 108 | @returns: records_added 109 | ''' 110 | try: 111 | cur.executemany(query_string, records) 112 | conn.commit() 113 | except Exception as e: 114 | Logger.error("Failed to write orders to database. {}".format(e), exc_info=True) 115 | raise Exception 116 | records_added = conn.total_changes 117 | if not keepalive: 118 | cur.execute("PRAGMA optimize") 119 | conn.close() 120 | return records_added 121 | -------------------------------------------------------------------------------- /gui.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | '''Simple wxpython GUI with 1 frame, using persistent properties.''' 6 | # ********************************************************************** 7 | import datetime 8 | import logging 9 | import os 10 | import webbrowser 11 | 12 | import wx 13 | import wx.grid as WXG 14 | import wx.lib.agw.persist as pm 15 | 16 | import config 17 | 18 | import db 19 | 20 | import aboutdialog 21 | import highlightdialog 22 | import ignoredialog 23 | import sortarray 24 | import statusmsg 25 | # cSpell Checker - Correct Words**************************************** 26 | # // cSpell:words wrusssian, wxpython, HRULES, VRULES, ELLIPSIZE, zkill, 27 | # // cSpell:words blops, Unregister, russsian, chkversion, posix, 28 | # // cSpell:words Gallente, Minmatar, Amarr, Caldari, ontop, hics, npsi 29 | # ********************************************************************** 30 | Logger = logging.getLogger(__name__) 31 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 32 | 33 | 34 | class Frame(wx.Frame): 35 | def __init__(self, *args, **kwds): 36 | 37 | # Persistent Options 38 | self.options = config.OPTIONS_OBJECT 39 | 40 | kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE # wx.RESIZE_BORDER 41 | wx.Frame.__init__(self, *args, **kwds) 42 | self.SetName("Main Window") 43 | 44 | self.Font = self.Font.Scaled(self.options.Get("FontScale", 1)) 45 | 46 | # Set stay on-top unless user deactivated it 47 | if self.options.Get("StayOnTop", True): 48 | self.ToggleWindowStyle(wx.STAY_ON_TOP) 49 | 50 | # Set parameters for columns 51 | self.columns = ( 52 | # Index, Heading, Format, Default Width, Can Toggle, Default Show, Menu Name, Outlist Column 53 | [0, "ID", wx.ALIGN_LEFT, 0, False, False, "", 0], 54 | [1, "Warning", wx.ALIGN_LEFT, 80, True, True, "Warning\tCTRL+ALT+X"], 55 | [2, "Faction ID", wx.ALIGN_LEFT, 0, False, False, "", 1], 56 | [3, "Character", wx.ALIGN_LEFT, 100, False, True, "", 2], 57 | [4, "Security", wx.ALIGN_RIGHT, 50, True, False, "&Security\tCTRL+ALT+S", 15], 58 | [5, "CorpID", wx.ALIGN_LEFT, 0, False, False, "", 3], 59 | [6, "Corporation", wx.ALIGN_LEFT, 100, True, True, "Cor&poration\tCTRL+ALT+P", 4], 60 | [7, "AllianceID", wx.ALIGN_LEFT, 0, False, False, "-", 5], 61 | [8, "Alliance", wx.ALIGN_LEFT, 150, True, True, "All&iance\tCTRL+ALT+I", 6], 62 | [9, "Faction", wx.ALIGN_LEFT, 50, True, False, "&Faction\tCTRL+ALT+F", 7], 63 | [10, "Kills", wx.ALIGN_RIGHT, 50, True, True, "&Kills\tCTRL+ALT+K", 10], 64 | [11, "Losses", wx.ALIGN_RIGHT, 50, True, True, "&Losses\tCTRL+ALT+L", 13], 65 | [12, "Last Wk", wx.ALIGN_RIGHT, 50, True, True, "Last &Wk\tCTRL+ALT+W", 9], 66 | [13, "Solo", wx.ALIGN_RIGHT, 50, True, False, "S&olo\tCTRL+ALT+O", 14], 67 | [14, "BLOPS", wx.ALIGN_RIGHT, 50, True, False, "&BLOPS\tCTRL+ALT+B", 11], 68 | [15, "HICs", wx.ALIGN_RIGHT, 50, True, False, "&HICs\tCTRL+ALT+H", 12], 69 | [16, "Last Loss", wx.ALIGN_RIGHT, 60, True, True, "Days since last Loss\tCTRL+ALT+[", 16], 70 | [17, "Last Kill", wx.ALIGN_RIGHT, 60, True, True, "Days since last Kill\tCTRL+ALT+]", 17], 71 | [18, "Avg. Attackers", wx.ALIGN_RIGHT, 100, True, True, "&Average Attackers\tCTRL+ALT+A", 18], 72 | [19, "Covert Cyno", wx.ALIGN_RIGHT, 100, True, True, "&Covert Cyno Probability\tCTRL+ALT+C", 19], 73 | [20, "Regular Cyno", wx.ALIGN_RIGHT, 100, True, True, "&Regular Cyno Probability\tCTRL+ALT+R", 20], 74 | [21, "Last Covert Cyno", wx.ALIGN_RIGHT, 100, True, True, "&Last Covert Cyno Ship Loss\tCTRL+ALT+<", 21], 75 | [22, "Last Regular Cyno", wx.ALIGN_RIGHT, 110, True, True, "&Last Regular Cyno Ship Loss\tCTRL+ALT+>", 22], 76 | [23, "Abyssal Losses", wx.ALIGN_RIGHT, 100, True, False, "&Abyssal Losses\tCTRL+ALT+Y", 23], 77 | [24, "", None, 1, False, True, ""], # Need for _stretchLastCol() 78 | ) 79 | 80 | # Define the menu bar and menu items 81 | self.menubar = wx.MenuBar() 82 | self.menubar.SetName("Menubar") 83 | if os.name == "nt": # For Windows 84 | self.file_menu = wx.Menu() 85 | self.file_about = self.file_menu.Append(wx.ID_ANY, '&About\tCTRL+A') 86 | self.file_menu.Bind(wx.EVT_MENU, self._openAboutDialog, self.file_about) 87 | self.file_quit = self.file_menu.Append(wx.ID_ANY, 'Quit PySpy') 88 | self.file_menu.Bind(wx.EVT_MENU, self.OnQuit, self.file_quit) 89 | self.menubar.Append(self.file_menu, 'File') 90 | if os.name == "posix": # For macOS 91 | self.help_menu = wx.Menu() 92 | self.help_about = self.help_menu.Append(wx.ID_ANY, '&About\tCTRL+A') 93 | self.help_menu.Bind(wx.EVT_MENU, self._openAboutDialog, self.help_about) 94 | self.menubar.Append(self.help_menu, 'Help') 95 | 96 | # View menu is platform independent 97 | self.view_menu = wx.Menu() 98 | 99 | self._createShowColMenuItems() 100 | 101 | self.view_menu.AppendSeparator() 102 | 103 | # Ignore Factions submenu for view menu 104 | self.factions_sub = wx.Menu() 105 | self.view_menu.Append(wx.ID_ANY, "Ignore Factions", self.factions_sub) 106 | 107 | self.ignore_galmin = self.factions_sub.AppendRadioItem(wx.ID_ANY, "Gallente / Minmatar") 108 | self.factions_sub.Bind(wx.EVT_MENU, self._toggleIgnoreFactions, self.ignore_galmin) 109 | self.ignore_galmin.Check(self.options.Get("IgnoreGalMin", False)) 110 | 111 | self.ignore_amacal = self.factions_sub.AppendRadioItem(wx.ID_ANY, "Amarr / Caldari") 112 | self.factions_sub.Bind(wx.EVT_MENU, self._toggleIgnoreFactions, self.ignore_amacal) 113 | self.ignore_amacal.Check(self.options.Get("IgnoreAmaCal", False)) 114 | 115 | self.ignore_none = self.factions_sub.AppendRadioItem(wx.ID_ANY, "None") 116 | self.factions_sub.Bind(wx.EVT_MENU, self._toggleIgnoreFactions, self.ignore_none) 117 | self.ignore_none.Check(self.options.Get("IgnoreNone", True)) 118 | 119 | # Higlighting submenu for view menu 120 | self.hl_sub = wx.Menu() 121 | self.view_menu.Append(wx.ID_ANY, "Highlighting", self.hl_sub) 122 | 123 | self.hl_blops = self.hl_sub.AppendCheckItem(wx.ID_ANY, "&BLOPS Kills\t(red)") 124 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_blops) 125 | self.hl_blops.Check(self.options.Get("HlBlops", True)) 126 | 127 | self.hl_hic = self.hl_sub.AppendCheckItem(wx.ID_ANY, "&HIC Losses\t(red)") 128 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_hic) 129 | self.hl_hic.Check(self.options.Get("HlHic", True)) 130 | 131 | self.hl_cyno = self.hl_sub.AppendCheckItem( 132 | wx.ID_ANY, 133 | "Cyno Characters (>" + 134 | "{:.0%}".format(config.CYNO_HL_PERCENTAGE) + 135 | " cyno losses)\t(blue)" 136 | ) 137 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_cyno) 138 | self.hl_cyno.Check(self.options.Get("HlCyno", True)) 139 | 140 | self.hl_list = self.hl_sub.AppendCheckItem(wx.ID_ANY, "&Highlighted Entities List\t(pink)") 141 | self.hl_sub.Bind(wx.EVT_MENU, self._toggleHighlighting, self.hl_list) 142 | self.hl_list.Check(self.options.Get("HlList", True)) 143 | 144 | # Font submenu for font scale 145 | self.font_sub = wx.Menu() 146 | self.view_menu.Append(wx.ID_ANY, "Font Scale", self.font_sub) 147 | 148 | self._fontScaleMenu(config.FONT_SCALE_MIN, config.FONT_SCALE_MAX) 149 | 150 | self.view_menu.AppendSeparator() 151 | 152 | # Toggle Stay on-top 153 | self.stay_ontop = self.view_menu.AppendCheckItem( 154 | wx.ID_ANY, 'Stay on-&top\tCTRL+T' 155 | ) 156 | self.view_menu.Bind(wx.EVT_MENU, self._toggleStayOnTop, self.stay_ontop) 157 | self.stay_ontop.Check(self.options.Get("StayOnTop", True)) 158 | 159 | # Toggle Dark-Mode 160 | self.dark_mode = self.view_menu.AppendCheckItem( 161 | wx.ID_ANY, '&Dark Mode\tCTRL+D' 162 | ) 163 | self.dark_mode.Check(self.options.Get("DarkMode", False)) 164 | self.view_menu.Bind(wx.EVT_MENU, self._toggleDarkMode, self.dark_mode) 165 | self.use_dm = self.dark_mode.IsChecked() 166 | 167 | self.menubar.Append(self.view_menu, 'View ') # Added space to avoid autogenerated menu items on Mac 168 | 169 | # Options Menubar 170 | self.opt_menu = wx.Menu() 171 | 172 | self.review_ignore = self.opt_menu.Append(wx.ID_ANY, "&Review Ignored Entities\tCTRL+R") 173 | self.opt_menu.Bind( 174 | wx.EVT_MENU, 175 | self._openIgnoreDialog, 176 | self.review_ignore 177 | ) 178 | 179 | self.review_highlight = self.opt_menu.Append(wx.ID_ANY, "&Review Highlighted Entities\tCTRL+H") 180 | self.opt_menu.Bind( 181 | wx.EVT_MENU, 182 | self._openHightlightDialog, 183 | self.review_highlight 184 | ) 185 | 186 | self.opt_menu.AppendSeparator() 187 | 188 | self.ignore_all = self.opt_menu.Append(wx.ID_ANY, "&Set NPSI Ignore List\tCTRL+SHIFT+S") 189 | self.opt_menu.Bind( 190 | wx.EVT_MENU, 191 | self._showNpsiDialog, 192 | self.ignore_all 193 | ) 194 | 195 | self.clear_ignore = self.opt_menu.Append(wx.ID_ANY, "&Clear NPSI Ignore List\tCTRL+SHIFT+C") 196 | self.opt_menu.Bind( 197 | wx.EVT_MENU, 198 | self._clearNpsiList, 199 | self.clear_ignore 200 | ) 201 | 202 | self.opt_menu.AppendSeparator() 203 | 204 | # Toggle zKillboard linking mode 205 | self.zkill_mode = self.opt_menu.AppendCheckItem( 206 | wx.ID_ANY, '&zKillboard Advanced Linking\tCTRL+ALT+Z' 207 | ) 208 | self.zkill_mode.Check(self.options.Get("ZkillMode", False)) 209 | self.opt_menu.Bind(wx.EVT_MENU, self._toggleZkillMode, self.zkill_mode) 210 | self.use_adv_zkill = self.zkill_mode.IsChecked() 211 | 212 | self.opt_menu.AppendSeparator() 213 | 214 | self.clear_cache = self.opt_menu.Append(wx.ID_ANY, '&Clear Character Cache') 215 | self.opt_menu.Bind(wx.EVT_MENU, self.clear_character_cache, self.clear_cache) 216 | # self.file_about = self.file_menu.Append(wx.ID_ANY, '&About\tCTRL+A') 217 | # self.file_menu.Bind(wx.EVT_MENU, self._openAboutDialog, self.file_about) 218 | 219 | self.menubar.Append(self.opt_menu, 'Options') 220 | 221 | # Set the grid object 222 | self.grid = wx.grid.Grid(self, wx.ID_ANY) 223 | self.grid.CreateGrid(0, 0) 224 | self.grid.SetName("Output List") 225 | 226 | # Allow to change window transparency using this slider. 227 | self.alpha_slider = wx.Slider(self, wx.ID_ANY, 250, 50, 255) 228 | self.alpha_slider.SetName("Transparency_Slider") 229 | self.Bind(wx.EVT_SLIDER, self._setTransparency) 230 | 231 | # The status label shows various info and error messages. 232 | self.status_label = wx.StaticText( 233 | self, 234 | wx.ID_ANY, 235 | "Please copy some EVE character names to clipboard...", 236 | style=wx.ALIGN_LEFT | wx.ST_ELLIPSIZE_END 237 | ) 238 | self.status_label.SetName("Status_Bar") 239 | 240 | # First set default properties, then restore persistence if any 241 | self.__set_properties() 242 | 243 | # Set up Persistence Manager 244 | self._persistMgr = pm.PersistenceManager.Get() 245 | self._persistMgr.SetPersistenceFile(config.GUI_CFG_FILE) 246 | self._persistMgr.RegisterAndRestoreAll(self) 247 | 248 | # Column resize to trigger last column stretch to fill blank canvas. 249 | self.Bind(wx.grid.EVT_GRID_COL_SIZE, self._stretchLastCol, self.grid) 250 | 251 | # Window resize to trigger last column stretch to fill blank canvas. 252 | self.Bind(wx.EVT_SIZE, self._stretchLastCol, self) 253 | 254 | # Ensure that Persistence Manager saves window location on close 255 | self.Bind(wx.EVT_CLOSE, self.OnClose) 256 | 257 | # Bind double click on list item to zKill link. 258 | self.Bind(wx.grid.EVT_GRID_CELL_LEFT_DCLICK, self._goToZKill, self.grid) 259 | 260 | # Bind right click on list item to ignore character. 261 | self.Bind(wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self._showContextMenu, self.grid) 262 | 263 | # Bind left click on column label to sorting 264 | self.Bind(wx.grid.EVT_GRID_COL_SORT, self.sortOutlist, self.grid) 265 | 266 | # Set transparency based off restored slider 267 | self._setTransparency() 268 | self.__do_layout() 269 | 270 | def __set_properties(self, dark_toggle=None): 271 | ''' 272 | Set the initial properties for the various widgets. 273 | 274 | :param `dark_toggle`: Boolean indicating if only the properties 275 | related to the colour scheme should be set or everything. 276 | ''' 277 | # Colour Scheme Dictionaries 278 | self.dark_dict = config.DARK_MODE 279 | self.normal_dict = config.NORMAL_MODE 280 | 281 | # Colour Scheme 282 | if not self.options.Get("DarkMode", False): 283 | self.bg_colour = self.normal_dict["BG"] 284 | self.txt_colour = self.normal_dict["TXT"] 285 | self.lne_colour = self.normal_dict["LNE"] 286 | self.lbl_colour = self.normal_dict["LBL"] 287 | self.hl1_colour = self.normal_dict["HL1"] 288 | self.hl2_colour = self.normal_dict["HL2"] 289 | self.hl3_colour = self.normal_dict["HL3"] 290 | else: 291 | self.bg_colour = self.dark_dict["BG"] 292 | self.txt_colour = self.dark_dict["TXT"] 293 | self.lne_colour = self.dark_dict["LNE"] 294 | self.lbl_colour = self.dark_dict["LBL"] 295 | self.hl1_colour = self.dark_dict["HL1"] 296 | self.hl2_colour = self.dark_dict["HL2"] 297 | self.hl3_colour = self.dark_dict["HL3"] 298 | 299 | # Set default colors 300 | self.SetBackgroundColour(self.bg_colour) 301 | self.SetForegroundColour(self.txt_colour) 302 | self.grid.SetDefaultCellBackgroundColour(self.bg_colour) 303 | self.grid.SetDefaultCellTextColour(self.txt_colour) 304 | self.grid.SetGridLineColour(self.lne_colour) 305 | self.grid.SetLabelBackgroundColour(self.bg_colour) 306 | self.grid.SetLabelTextColour(self.lbl_colour) 307 | self.status_label.SetForegroundColour(self.lbl_colour) 308 | 309 | # Do not reset window size etc. if only changing colour scheme. 310 | if dark_toggle: 311 | return 312 | 313 | self.SetTitle(config.GUI_TITLE) 314 | self.SetSize((720, 400)) 315 | self.SetMenuBar(self.menubar) 316 | # Insert columns based on parameters provided in col_def 317 | 318 | # self.grid.CreateGrid(0, 0) 319 | if self.grid.GetNumberCols() < len(self.columns): 320 | self.grid.AppendCols(len(self.columns)) 321 | self.grid.SetColLabelSize(self.grid.GetDefaultRowSize() + 2) 322 | self.grid.SetRowLabelSize(0) 323 | self.grid.EnableEditing(0) 324 | self.grid.DisableCellEditControl() 325 | self.grid.EnableDragRowSize(0) 326 | self.grid.EnableDragGridSize(0) 327 | self.grid.SetSelectionMode(wx.grid.Grid.SelectRows) 328 | self.grid.SetColLabelAlignment(wx.ALIGN_CENTRE, wx.ALIGN_BOTTOM) 329 | self.grid.ClipHorzGridLines(False) 330 | # self.grid.ClipVertGridLines(False) 331 | # Disable visual highlighting of selected cell to look more like listctrl 332 | self.grid.SetCellHighlightPenWidth(0) 333 | colidx = 0 334 | for col in self.columns: 335 | self.grid.SetColLabelValue( 336 | col[0], # Index 337 | col[1], # Heading 338 | ) 339 | # self.grid.SetColSize(colidx, col[3]) 340 | colidx += 1 341 | # Transparency slider 342 | self.alpha_slider.SetMinSize((100, 20)) 343 | # Window icon 344 | icon = wx.Icon() 345 | icon.CopyFromBitmap(wx.Bitmap(config.ICON_FILE, wx.BITMAP_TYPE_ANY)) 346 | self.SetIcon(icon) 347 | 348 | def __do_layout(self): 349 | ''' 350 | Assigns the various widgets to sizers and calls a number of helper 351 | functions. 352 | ''' 353 | sizer_main = wx.BoxSizer(wx.VERTICAL) 354 | sizer_bottom = wx.BoxSizer(wx.HORIZONTAL) 355 | sizer_main.Add(self.grid, 1, wx.EXPAND, 0) 356 | sizer_bottom.Add(self.status_label, 1, wx.ALIGN_CENTER_VERTICAL, 0) 357 | static_line = wx.StaticLine(self, wx.ID_ANY, style=wx.LI_VERTICAL) 358 | sizer_bottom.Add(static_line, 0, wx.EXPAND, 0) 359 | sizer_bottom.Add(self.alpha_slider, 0, wx.ALIGN_RIGHT, 0) 360 | sizer_main.Add(sizer_bottom, 0, wx.ALIGN_BOTTOM | wx.ALL | wx.EXPAND, 1) 361 | self.SetSizer(sizer_main) 362 | self.Layout() 363 | self._restoreColWidth() 364 | self._stretchLastCol() 365 | 366 | def _fontScaleMenu(self, lower, upper): 367 | ''' 368 | Populates the font scale sub menu with scale percentages 369 | based on input bounds. 370 | 371 | :param `lower`: The minimum scale represented as a decimal, e.g. 0.6 372 | 373 | :param `upper`: The maximum scale represented as a decimal, e.g. 1.4 374 | ''' 375 | for scale in range(lower, upper): 376 | scale = scale / 10 377 | self.font_sub.AppendRadioItem(wx.ID_ANY, "{:.0%}".format(scale)) 378 | self.font_sub.Bind( 379 | wx.EVT_MENU, 380 | lambda evt, scale=scale: self._setFontScale(scale, evt), 381 | self.font_sub.MenuItems[-1] 382 | ) 383 | if scale == self.options.Get("FontScale", 1): 384 | self.font_sub.MenuItems[-1].Check(True) 385 | 386 | def _setFontScale(self, scale, evt=None): 387 | ''' 388 | Changes the font scaling and saves it in the pickle container. 389 | 390 | :param `scale`: Float representing the font scale. 391 | ''' 392 | self.Font = self.Font.Scaled(scale) 393 | self.options.Set("FontScale", scale) 394 | 395 | def _createShowColMenuItems(self): 396 | ''' 397 | Populates the View menu with show column toggle menu items for 398 | each column that is toggleable. It uses the information provided 399 | in self.columns. 400 | ''' 401 | # For each column, create show / hide menu items, if hideable 402 | self.col_menu_items = [[] for i in self.columns] 403 | for col in self.columns: 404 | if not col[4]: # Do not add menu item if column not hideable 405 | continue 406 | index = col[0] 407 | options_key = "Show" + col[1] 408 | menu_name = "Show " + col[6] 409 | self.col_menu_items[index] = self.view_menu.AppendCheckItem( 410 | wx.ID_ANY, 411 | menu_name 412 | ) 413 | # Column show / hide, depending on user settings, if any 414 | checked = self.options.Get( 415 | options_key, 416 | self.columns[index][5] 417 | ) 418 | self.col_menu_items[index].Check( 419 | self.options.Get(options_key, checked) 420 | ) 421 | # Bind new menu item to toggleColumn method 422 | self.view_menu.Bind( 423 | wx.EVT_MENU, 424 | lambda evt, index=index: self._toggleColumn(index, evt), 425 | self.col_menu_items[index] 426 | ) 427 | 428 | def _toggleColumn(self, index, event=None): 429 | ''' 430 | Depending on the respective menu item state, either reveals or 431 | hides a column. If it hides a column, it first stores the old 432 | column width in self.options to allow for subsequent restore. 433 | 434 | :param `index`: Integer representing the index of the column 435 | which is to shown / hidden. 436 | ''' 437 | try: 438 | checked = self.col_menu_items[index].IsChecked() 439 | except: 440 | checked = False 441 | col_name = self.columns[index][1] 442 | if checked: 443 | default_width = self.columns[index][3] 444 | col_width = self.options.Get(col_name, default_width) 445 | if col_width > 0: 446 | self.grid.SetColSize(index, col_width) 447 | else: 448 | self.grid.SetColSize(index, default_width) 449 | else: 450 | col_width = self.grid.GetColSize(index) 451 | # Only save column status if column is actually hideable 452 | if self.columns[index][4]: 453 | self.options.Set(col_name, col_width) 454 | self.grid.HideCol(index) 455 | self._stretchLastCol() 456 | 457 | def _stretchLastCol(self, event=None): 458 | ''' 459 | Makes sure the last column fills any blank space of the 460 | grid. For this reason, the last list item of self.columns should 461 | describe an empty column. 462 | ''' 463 | grid_width = self.grid.Size.GetWidth() 464 | cols_width = 0 465 | for index in range(self.columns[-1][0] + 1): 466 | cols_width += self.grid.GetColSize(index) 467 | stretch_width = grid_width - cols_width 468 | last_col_width = max( 469 | self.grid.GetColSize(index) + stretch_width, 470 | self.columns[index][3] 471 | ) 472 | self.grid.SetColSize(index, last_col_width) 473 | self.Layout() 474 | if event is not None: 475 | event.Skip(True) 476 | 477 | def appendString(self, org, app): 478 | """ 479 | Appends a String to another string with a "+" if the org string is not "". 480 | 481 | :param org: Original String 482 | :param app: String which is to be appended to the org string 483 | :return: 484 | """ 485 | if org == "-": 486 | return app 487 | else: 488 | return org + " + " + app 489 | 490 | def updateList(self, outlist, duration=None): 491 | ''' 492 | `updateList()` takes the output of `output_list()` in `analyze.py` (via 493 | `sortOutlist()`) or a copy thereof stored in self.option, and uses it 494 | to populate the grid widget. Before it does so, it checks each 495 | item in outlist against a list of ignored characters, corporations 496 | and alliances. Finally, it highlights certain characters and 497 | updates the statusbar message. 498 | 499 | :param `outlist`: A list of rows with character data. 500 | 501 | :param `duration`: Time in seconds taken to query all relevant 502 | databases for each character. 503 | ''' 504 | # If updateList() gets called before outlist has been provided, do nothing 505 | if outlist is None: 506 | return 507 | # Clean up grid 508 | if self.grid.GetNumberRows() > 0: 509 | self.grid.DeleteRows(numRows=self.grid.GetNumberRows()) 510 | self.grid.AppendRows(len(outlist)) 511 | # Add any NPSI fleet related characters to ignored_list 512 | npsi_list = self.options.Get("NPSIList", default=[]) 513 | ignored_list = self.options.Get("ignoredList", default=[]) 514 | highlighted_list = self.options.Get("highlightedList", default=[]) 515 | hl_blops = self.options.Get("HlBlops", True) 516 | hl_hic = self.options.Get("HlHic", True) 517 | hl_cyno = self.options.Get("HlCyno", True) 518 | hl_list = self.options.Get("HlList", True) 519 | hl_cyno_prob = config.CYNO_HL_PERCENTAGE 520 | ignore_count = 0 521 | rowidx = 0 522 | for r in outlist: 523 | 524 | ignore = False 525 | for rec in ignored_list: 526 | if r[0] == rec[0] or r[3] == rec[0] or r[5] == rec[0]: 527 | ignore = True 528 | for rec in npsi_list: 529 | if r[0] == rec[0]: 530 | ignore = True 531 | if ignore: 532 | self.grid.HideRow(rowidx) 533 | ignore_count += 1 534 | 535 | # Schema depending on output_list() in analyze.py 536 | id = r[0] # Hidden, used for zKillboard link 537 | faction_id = r[1] # Hidden, used for faction ignoring 538 | name = r[2] 539 | corp_id = r[3] 540 | corp_name = r[4] 541 | alliance_id = r[5] 542 | alliance_name = r[6] 543 | faction = r[7] if r[7] is not None else "-" 544 | allies = "{:,}".format(int(r[8])) 545 | 546 | # Add number of allies to alliance name 547 | if alliance_name is not None: 548 | alliance_name = alliance_name + " (" + allies + ")" 549 | else: 550 | alliance_name = "-" 551 | 552 | # zKillboard data is "n.a." unless available 553 | week_kills = kills = blops_kills = hic_losses = "n.a." 554 | losses = solo_ratio = sec_status = "n.a." 555 | 556 | if r[13] is not None: 557 | week_kills = "{:,}".format(int(r[9])) if int(r[9]) >0 else "-" 558 | kills = "{:,}".format(int(r[10])) 559 | blops_kills = "{:,}".format(int(r[11])) if int(r[11]) >0 else "-" 560 | hic_losses = "{:,}".format(int(r[12])) if int(r[12]) >0 else "-" 561 | losses = "{:,}".format(int(r[13])) 562 | solo_ratio = "{:.0%}".format(float(r[14])) 563 | sec_status = "{:.1f}".format(float(r[15])) 564 | 565 | # PySpy proprietary data is "n.a." unless available 566 | last_loss = last_kill = covert_ship = normal_ship = "n.a." 567 | avg_attackers = covert_prob = normal_prob = abyssal_losses = "n.a." 568 | cov_prob_float = norm_prob_float = 0 569 | if r[16] is not None: 570 | 571 | if int(r[16]) > 0: 572 | last_loss = str(( 573 | datetime.date.today() - 574 | datetime.datetime.strptime(str(r[16]),'%Y%m%d').date() 575 | ).days) + "d" 576 | else: 577 | last_loss = "n.a." 578 | 579 | if int(r[17]) > 0: 580 | last_kill = str(( 581 | datetime.date.today() - 582 | datetime.datetime.strptime(str(r[17]),'%Y%m%d').date() 583 | ).days) + "d" 584 | else: 585 | last_kill = "n.a." 586 | 587 | avg_attackers = "{:.1f}".format(float(r[18])) 588 | cov_prob_float = r[19] 589 | covert_prob = "{:.0%}".format(cov_prob_float) if cov_prob_float >0 else "-" 590 | norm_prob_float = r[20] 591 | normal_prob = "{:.0%}".format(norm_prob_float) if norm_prob_float >0 else "-" 592 | covert_ship = r[21] 593 | normal_ship = r[22] 594 | abyssal_losses = r[23] if int(r[23]) >0 else "-" 595 | 596 | out = [ 597 | id, 598 | "-", 599 | faction_id, 600 | name, 601 | sec_status, 602 | corp_id, 603 | corp_name, 604 | alliance_id, 605 | alliance_name, 606 | faction, 607 | kills, 608 | losses, 609 | week_kills, 610 | solo_ratio, 611 | blops_kills, 612 | hic_losses, 613 | last_loss, 614 | last_kill, 615 | avg_attackers, 616 | covert_prob, 617 | normal_prob, 618 | covert_ship, 619 | normal_ship, 620 | abyssal_losses 621 | ] 622 | 623 | # Check if character belongs to a faction that should be ignored 624 | if faction_id != 0: 625 | if config.IGNORED_FACTIONS == 2 and faction_id % 2 == 0: 626 | self.grid.HideRow(rowidx) 627 | if config.IGNORED_FACTIONS == 1 and faction_id % 2 != 0: 628 | self.grid.HideRow(rowidx) 629 | colidx = 0 630 | 631 | if hl_blops and r[9] is not None and r[11] > 0: # Add BLOPS to Warning Column 632 | out[1] = self.appendString(out[1], "BLOPS") 633 | if hl_hic and r[9] is not None and r[12] > 0: 634 | out[1] = self.appendString(out[1], "HIC") # Add HIC to Warning Column 635 | if hl_cyno and ( 636 | cov_prob_float >= hl_cyno_prob or norm_prob_float >= hl_cyno_prob): # Add CYNO to Warnin Column 637 | out[1] = self.appendString(out[1], "CYNO") 638 | 639 | # Cell text formatting 640 | for value in out: 641 | color = False 642 | self.grid.SetCellValue(rowidx, colidx, str(value)) 643 | self.grid.SetCellAlignment(self.columns[colidx][2], rowidx, colidx) 644 | if hl_blops and r[9] is not None and r[11] > 0: # Highlight BLOPS chars 645 | self.grid.SetCellTextColour(rowidx, colidx, self.hl1_colour) 646 | color = True 647 | if hl_hic and r[9] is not None and r[12] > 0: # Highlight HIC chars 648 | self.grid.SetCellTextColour(rowidx, colidx, self.hl1_colour) 649 | color = True 650 | if hl_cyno and ( 651 | cov_prob_float >= hl_cyno_prob or norm_prob_float >= hl_cyno_prob): # Highlight CYNO chars 652 | self.grid.SetCellTextColour(rowidx, colidx, self.hl2_colour) 653 | color = True 654 | 655 | for entry in highlighted_list: # Highlight chars from highlight list 656 | if hl_list and (entry[1] == out[3] or entry[1] == out[6]or entry[1] == out[8][:-4]): 657 | self.grid.SetCellTextColour(rowidx, colidx, self.hl3_colour) 658 | color = True 659 | 660 | if not color: 661 | self.grid.SetCellTextColour(rowidx, colidx, self.txt_colour) 662 | colidx += 1 663 | rowidx += 1 664 | 665 | if duration is not None: 666 | statusmsg.push_status( 667 | str(len(outlist) - ignore_count) + 668 | " characters analysed, in " + str(duration) + 669 | " seconds (" + str(ignore_count) + " ignored). Double click " + 670 | "character to go to zKillboard." 671 | ) 672 | else: 673 | statusmsg.push_status( 674 | str(len(outlist) - ignore_count) + " characters analysed (" + 675 | str(ignore_count) + " ignored). Double click character to go " + 676 | " to zKillboard." 677 | ) 678 | 679 | def updateStatusbar(self, msg): 680 | '''Gets called by push_status() in statusmsg.py.''' 681 | if isinstance(msg, str): 682 | self.status_label.SetLabel(msg) 683 | self.Layout() 684 | 685 | def _goToZKill(self, event): 686 | rowidx = event.GetRow() 687 | 688 | url = "https://zkillboard.com/" 689 | 690 | # If we want zkillboard advanced linking then just link to the character sheet 691 | if self.options.Get("ZkillMode", False): 692 | colidx = event.GetCol() 693 | 694 | # Corporation was clicked on, link to that killboard 695 | if colidx == 6: 696 | corporation_id = self.options.Get("outlist")[rowidx][3] 697 | url = url + "corporation/" + str(corporation_id) + "/" 698 | 699 | # Alliance was clicked on, link to that killboard if alliance exists 700 | elif colidx == 8: 701 | alliance_id = self.options.Get("outlist")[rowidx][5] 702 | if alliance_id != None: 703 | url = url + "alliance/" + str(alliance_id) + "/" 704 | 705 | # Faction was clicked on, link to that killboard if faction exists 706 | elif colidx == 9: 707 | faction_id = self.options.Get("outlist")[rowidx][1] 708 | if faction_id != None: 709 | url = url + "faction/" + str(faction_id) + "/" 710 | 711 | # Something other than character was clicked on but we want to look at the character with modifiers 712 | elif colidx != 3: 713 | # Set up the character base url 714 | character_id = self.options.Get("outlist")[rowidx][0] 715 | url = url + "character/" + str(character_id) + "/" 716 | 717 | # Kills modifier 718 | if colidx == 10: 719 | url = url + "kills/" 720 | # Losses modifier 721 | elif colidx == 11: 722 | url = url + "losses/" 723 | # Solo Modifier 724 | elif colidx == 13: 725 | url = url + "solo/" 726 | # BLOPS Modifer 727 | elif colidx == 14: 728 | url = url + "group/898/" 729 | # HIC Modifier 730 | elif colidx == 15: 731 | url = url + "group/894/" 732 | # Abyssal Modifier 733 | elif colidx == 23: 734 | url = url + "abyssal/" 735 | 736 | # This is a catch all if the url wasnt set or if a column other than a special one was clicked. 737 | # This is in a seperate flow to the above in case any of the above need to fall through 738 | if url == "https://zkillboard.com/": 739 | character_id = self.options.Get("outlist")[rowidx][0] 740 | url = url + "character/" + str(character_id) + "/" 741 | 742 | webbrowser.open_new_tab(url) 743 | 744 | def _showContextMenu(self, event): 745 | ''' 746 | Gets invoked by right click on any list item and produces a 747 | context menu that allows the user to add the selected character/corp/alliance 748 | to PySpy's list of "ignored characters" which will no longer be 749 | shown in search results and add the selected character/corp/alliance 750 | to PySpy's list of "highlighted characters" which will hihglight them in the grid. 751 | ''' 752 | def OnIgnore(id, name, type, e=None): 753 | ignored_list = self.options.Get("ignoredList", default=[]) 754 | ignored_list.append([id, name, type]) 755 | self.options.Set("ignoredList", ignored_list) 756 | self.updateList(self.options.Get("outlist", None)) 757 | 758 | def OnHighlight(id, name, type, e=None): 759 | highlighted_list = self.options.Get("highlightedList", default=[]) 760 | if [id, name, type] not in highlighted_list: 761 | highlighted_list.append([id, name, type]) 762 | self.options.Set("highlightedList", highlighted_list) 763 | self.updateList(self.options.Get("outlist", None)) 764 | 765 | def OnDeHighlight(id, name, type, e=None): 766 | highlighted_list = self.options.Get("highlightedList", default=[]) 767 | highlighted_list.remove([id, name, type]) 768 | self.options.Set("highlightedList", highlighted_list) 769 | self.updateList(self.options.Get("outlist", None)) 770 | 771 | highlighted_list = self.options.Get("highlightedList", default=[]) 772 | rowidx = event.GetRow() 773 | character_id = str(self.options.Get("outlist")[rowidx][0]) 774 | # Only open context menu character item right clicked, not empty line. 775 | if len(character_id) > 0: 776 | outlist = self.options.Get("outlist") 777 | for r in outlist: 778 | if str(r[0]) == character_id: 779 | character_id = r[0] 780 | character_name = r[2] 781 | corp_id = r[3] 782 | corp_name = r[4] 783 | alliance_id = r[5] 784 | alliance_name = r[6] 785 | break 786 | self.menu = wx.Menu() 787 | # Context menu to ignore characters, corporations and alliances. 788 | item_ig_char = self.menu.Append( 789 | wx.ID_ANY, "Ignore character '" + character_name + "'" 790 | ) 791 | self.menu.Bind( 792 | wx.EVT_MENU, 793 | lambda evt, id=character_id, name=character_name: OnIgnore(id, name, "Character", evt), 794 | item_ig_char 795 | ) 796 | 797 | item_ig_corp = self.menu.Append( 798 | wx.ID_ANY, "Ignore corporation: '" + corp_name + "'" 799 | ) 800 | self.menu.Bind( 801 | wx.EVT_MENU, 802 | lambda evt, id=corp_id, name=corp_name: OnIgnore(id, name, "Corporation", evt), 803 | item_ig_corp 804 | ) 805 | 806 | if alliance_name is not None: 807 | item_ig_alliance = self.menu.Append( 808 | wx.ID_ANY, "Ignore alliance: '" + alliance_name + "'" 809 | ) 810 | self.menu.Bind( 811 | wx.EVT_MENU, 812 | lambda evt, id=alliance_id, name=alliance_name: OnIgnore(id, name, "Alliance", evt), 813 | item_ig_alliance 814 | ) 815 | 816 | self.menu.AppendSeparator() 817 | 818 | hl_char = False 819 | hl_corp = False 820 | hl_alliance = False 821 | 822 | for entry in highlighted_list: 823 | if entry[1] == self.options.Get("outlist")[rowidx][2]: 824 | hl_char = True 825 | if entry[1] == self.options.Get("outlist")[rowidx][4]: 826 | hl_corp = True 827 | if alliance_name is not None: 828 | if entry[1] == self.options.Get("outlist")[rowidx][6]: 829 | hl_alliance = True 830 | 831 | # Context menu to highlight characters, corporations and alliances 832 | if not hl_char: 833 | item_hl_char = self.menu.Append( 834 | wx.ID_ANY, "Highlight character '" + character_name + "'" 835 | ) 836 | self.menu.Bind( 837 | wx.EVT_MENU, 838 | lambda evt, id=character_id, name=character_name: OnHighlight(id, name, "Character", evt), 839 | item_hl_char 840 | ) 841 | else: 842 | item_hl_char = self.menu.Append( 843 | wx.ID_ANY, "Stop highlighting character '" + character_name + "'" 844 | ) 845 | self.menu.Bind( 846 | wx.EVT_MENU, 847 | lambda evt, id=character_id, name=character_name: OnDeHighlight(id, name, "Character", evt), 848 | item_hl_char 849 | ) 850 | 851 | if not hl_corp: 852 | item_hl_corp = self.menu.Append( 853 | wx.ID_ANY, "Highlight corporation '" + corp_name + "'" 854 | ) 855 | self.menu.Bind( 856 | wx.EVT_MENU, 857 | lambda evt, id=corp_id, name=corp_name: OnHighlight(id, name, "Corporation", evt), 858 | item_hl_corp 859 | ) 860 | else: 861 | item_hl_corp = self.menu.Append( 862 | wx.ID_ANY, "Stop highlighting corporation '" + corp_name + "'" 863 | ) 864 | self.menu.Bind( 865 | wx.EVT_MENU, 866 | lambda evt, id=corp_id, name=corp_name: OnDeHighlight(id, name, "Corporation", evt), 867 | item_hl_corp 868 | ) 869 | 870 | if alliance_name is not None: 871 | if not hl_alliance: 872 | item_hl_alliance = self.menu.Append( 873 | wx.ID_ANY, "Highlight alliance: '" + alliance_name + "'" 874 | ) 875 | self.menu.Bind( 876 | wx.EVT_MENU, 877 | lambda evt, id=alliance_id, name=alliance_name: OnHighlight(id, name, "Alliance", evt), 878 | item_hl_alliance 879 | ) 880 | else: 881 | item_hl_alliance = self.menu.Append( 882 | wx.ID_ANY, "Stop highlighting alliance: '" + alliance_name + "'" 883 | ) 884 | self.menu.Bind( 885 | wx.EVT_MENU, 886 | lambda evt, id=alliance_id, name=alliance_name: OnDeHighlight(id, name, "Alliance", evt), 887 | item_hl_alliance 888 | ) 889 | 890 | self.PopupMenu(self.menu, event.GetPosition()) 891 | self.menu.Destroy() 892 | 893 | def sortOutlist(self, event=None, outlist=None, duration=None): 894 | ''' 895 | If called by event handle, i.e. user 896 | ''' 897 | if event is None: 898 | # Default sort by character name ascending. 899 | colidx = self.options.Get("SortColumn", self.columns[3][7]) 900 | sort_desc = self.options.Get("SortDesc", False) 901 | else: 902 | colidx = event.GetCol() 903 | if self.options.Get("SortColumn", -1) == colidx: 904 | sort_desc = not self.options.Get("SortDesc") 905 | else: 906 | sort_desc = True 907 | 908 | # Use unicode characters for sort indicators 909 | arrow = u"\u2193" if sort_desc else u"\u2191" 910 | 911 | # Reset all labels 912 | for col in self.columns: 913 | self.grid.SetColLabelValue(col[0], col[1]) 914 | 915 | # Assign sort indicator to sort column 916 | self.grid.SetColLabelValue( 917 | colidx, 918 | self.columns[colidx][1] + " " + arrow 919 | ) 920 | self.options.Set("SortColumn", colidx) 921 | self.options.Set("SortDesc", sort_desc) 922 | event = None 923 | # Sort outlist. Note: outlist columns are not the same as 924 | # self.grid columns!!! 925 | if outlist is None: 926 | outlist = self.options.Get("outlist", False) 927 | 928 | if outlist and colidx != 1: 929 | outlist = sortarray.sort_array( 930 | outlist, 931 | self.columns[colidx][7], 932 | sec_col=self.columns[2][7], # Secondary sort by name 933 | prim_desc=sort_desc, 934 | sec_desc=False, # Secondary sort by name always ascending 935 | case_sensitive=False 936 | ) 937 | self.options.Set("outlist", outlist) 938 | self.updateList(outlist, duration=duration) 939 | 940 | def _setTransparency(self, event=None): 941 | ''' 942 | Sets window transparency based off slider setting and stores 943 | value in self.options. 944 | ''' 945 | alpha = self.alpha_slider.GetValue() 946 | self.SetTransparent(alpha) 947 | self.options.Set("GuiAlpha", alpha) 948 | self.Layout() 949 | 950 | def updateAlert(self, latest_ver, cur_ver): 951 | ''' 952 | Simple dialog box to notify user when new version of PySpy is 953 | available for download. Gets called by chk_github_update() 954 | in chkversion.py. 955 | ''' 956 | self.ToggleWindowStyle(wx.STAY_ON_TOP) 957 | msgbox = wx.MessageBox( 958 | "PySpy " + str(latest_ver) + " is now available. You are running " + 959 | str(cur_ver) + ". Would you like to update now?", 960 | 'Update Available', 961 | wx.YES_NO | wx.YES_DEFAULT | wx.ICON_INFORMATION | wx.STAY_ON_TOP 962 | ) 963 | if msgbox == wx.YES: 964 | webbrowser.open_new_tab( 965 | "https://github.com/Eve-PySpy/PySpy/releases/latest" 966 | ) 967 | self.ToggleWindowStyle(wx.STAY_ON_TOP) 968 | 969 | def _toggleIgnoreFactions(self, e): 970 | ig_galmin = self.ignore_galmin.IsChecked() 971 | ig_amacal = self.ignore_amacal.IsChecked() 972 | ig_none = self.ignore_none.IsChecked() 973 | if ig_galmin: 974 | config.IGNORED_FACTIONS = 2 # Gallente & Minmatar have even ids 975 | self.options.Set("IgnoredFactions", 2) 976 | if ig_amacal: 977 | config.IGNORED_FACTIONS = 1 # Amarr & Caldari have uneven ids 978 | self.options.Set("IgnoredFactions", 1) 979 | if ig_none: 980 | config.IGNORED_FACTIONS = None # Amarr & Caldari have uneven ids 981 | self.options.Set("IgnoredFactions", 0) 982 | self.updateList(self.options.Get("outlist", None)) 983 | 984 | def _toggleHighlighting(self, e): 985 | self.options.Set("HlBlops", self.hl_blops.IsChecked()) 986 | self.options.Set("HlCyno", self.hl_cyno.IsChecked()) 987 | self.options.Set("HlHic", self.hl_hic.IsChecked()) 988 | self.options.Set("HlList", self.hl_list.IsChecked()) 989 | self.updateList(self.options.Get("outlist", None)) 990 | 991 | def _toggleStayOnTop(self, evt=None): 992 | self.options.Set("StayOnTop", self.stay_ontop.IsChecked()) 993 | self.ToggleWindowStyle(wx.STAY_ON_TOP) 994 | 995 | def _toggleDarkMode(self, evt=None): 996 | self.options.Set("DarkMode", self.dark_mode.IsChecked()) 997 | self.use_dm = self.dark_mode.IsChecked() 998 | self.__set_properties(dark_toggle=True) 999 | self.Refresh() 1000 | self.Update() 1001 | self.updateList(self.options.Get("outlist")) 1002 | 1003 | def _openAboutDialog(self, evt=None): 1004 | ''' 1005 | Checks if AboutDialog is already open. If not, opens the dialog 1006 | window, otherwise brings the existing dialog window to the front. 1007 | ''' 1008 | for c in self.GetChildren(): 1009 | if c.GetName() == "AboutDialog": # Needs to match name in aboutdialog.py 1010 | c.Raise() 1011 | return 1012 | aboutdialog.showAboutBox(self) 1013 | 1014 | def _openIgnoreDialog(self, evt=None): 1015 | ''' 1016 | Checks if IgnoreDialog is already open. If not, opens the dialog 1017 | window, otherwise brings the existing dialog window to the front. 1018 | ''' 1019 | for c in self.GetChildren(): 1020 | if c.GetName() == "IgnoreDialog": # Needs to match name in ignoredialog.py 1021 | c.Raise() 1022 | return 1023 | ignoredialog.showIgnoreDialog(self) 1024 | 1025 | def _openHightlightDialog(self, evt=None): 1026 | ''' 1027 | Checks if HightlightDialog is already open. If not, opens the dialog 1028 | window, otherwise brings the existing dialog window to the front. 1029 | ''' 1030 | for c in self.GetChildren(): 1031 | if c.GetName() == "HighlightDialog": # Needs to match name in highlightdialog.py 1032 | c.Raise() 1033 | return 1034 | highlightdialog.showHighlightDialog(self) 1035 | 1036 | def _showNpsiDialog(self, evt=None): 1037 | dialog = wx.MessageBox( 1038 | "Do you want to ignore all currently shown characters? " + 1039 | "You can undo this under `Options > Clear NPSI Ignore List`.", 1040 | "NPSI Ignore List", 1041 | wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION 1042 | ) 1043 | if dialog == 2: # Yes 1044 | npsi_list = [] 1045 | outlist = self.options.Get("outlist", None) 1046 | if outlist is None: 1047 | return 1048 | for r in outlist: 1049 | character_id = [r[0]] # Needs to be list to append to ignored_list 1050 | npsi_list.append(character_id) 1051 | self.options.Set("NPSIList", npsi_list) 1052 | self.updateList(outlist) 1053 | 1054 | def _clearNpsiList(self, evt=None): 1055 | dialog = wx.MessageBox( 1056 | "Would you like to clear the current NPSI fleet ignore list?", 1057 | "NPSI Ignore List", 1058 | wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION 1059 | ) 1060 | if dialog == 2: # Yes 1061 | self.options.Set("NPSIList", []) 1062 | self.updateList(self.options.Get("outlist", None)) 1063 | 1064 | def _toggleZkillMode(self, evt=None): 1065 | self.options.Set("ZkillMode", self.zkill_mode.IsChecked()) 1066 | self.use_adv_zkill = self.zkill_mode.IsChecked() 1067 | # This just prevents all settings from being updated. 1068 | self.__set_properties(dark_toggle=True) 1069 | self.Refresh() 1070 | self.Update() 1071 | self.updateList(self.options.Get("outlist")) 1072 | 1073 | def _restoreColWidth(self): 1074 | ''' 1075 | Restores column width either to default or value stored from 1076 | previous session. 1077 | ''' 1078 | for col in self.columns: 1079 | header = col[1] 1080 | # Column width is also set in _toggleColumn() 1081 | width = self.options.Get(header, col[3]) 1082 | menu_item = self.col_menu_items[col[0]] 1083 | if menu_item == [] or menu_item.IsChecked(): 1084 | self.grid.SetColSize(col[0], width) 1085 | else: 1086 | self.grid.SetColSize(col[0], 0) 1087 | pass 1088 | 1089 | def _saveColumns(self): 1090 | ''' 1091 | Saves custom column widths, since wxpython's Persistence Manager 1092 | is unable to do so for Grid widgets. 1093 | ''' 1094 | for col in self.columns: 1095 | is_hideable = col[4] 1096 | default_show = col[5] 1097 | header = col[1] 1098 | options_key = "Show" + header 1099 | width = self.grid.GetColSize(col[0]) 1100 | try: 1101 | menu_item_chk = self.col_menu_items[col[0]].IsChecked() 1102 | except: 1103 | menu_item_chk = False 1104 | # Only save column width for columns that are not hidden or 1105 | # not hideable and shown by default. 1106 | if menu_item_chk or (not is_hideable and default_show): 1107 | self.options.Set(header, width) 1108 | # Do not add menu item if column not hideable 1109 | if col[4]: 1110 | self.options.Set(options_key, menu_item_chk) 1111 | pass 1112 | 1113 | def OnClose(self, event=None): 1114 | ''' 1115 | Run a few clean-up tasks on close and save persistent properties. 1116 | ''' 1117 | self._persistMgr.SaveAndUnregister() 1118 | 1119 | # Save column toggle menu state and column width in pickle container 1120 | self._saveColumns() 1121 | 1122 | # Store check-box values in pickle container 1123 | self.options.Set("HlBlops", self.hl_blops.IsChecked()) 1124 | self.options.Set("HlCyno", self.hl_cyno.IsChecked()) 1125 | self.options.Set("IgnoreGalMin", self.ignore_galmin.IsChecked()) 1126 | self.options.Set("IgnoreAmaCal", self.ignore_amacal.IsChecked()) 1127 | self.options.Set("IgnoreNone", self.ignore_none.IsChecked()) 1128 | self.options.Set("StayOnTop", self.stay_ontop.IsChecked()) 1129 | self.options.Set("DarkMode", self.dark_mode.IsChecked()) 1130 | # Delete last outlist and NPSIList 1131 | self.options.Set("outlist", None) 1132 | self.options.Set("NPSIList", []) 1133 | # Write pickle container to disk 1134 | self.options.Save() 1135 | event.Skip() if event else False 1136 | 1137 | def OnQuit(self, e): 1138 | self.Close() 1139 | 1140 | def clear_character_cache(self, e): 1141 | conn, cur = db.connect_persistent_db() 1142 | query_statement = '''DELETE FROM characters''' 1143 | cur.execute(query_statement) 1144 | conn.commit() 1145 | conn.close() 1146 | statusmsg.push_status("Cleared character cache") 1147 | 1148 | class App(wx.App): 1149 | def OnInit(self): 1150 | self.PySpy = Frame(None, wx.ID_ANY, "") 1151 | self.SetTopWindow(self.PySpy) 1152 | self.PySpy.Show() 1153 | return True 1154 | -------------------------------------------------------------------------------- /highlightdialog.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' Dialog to view and remove entities from PySpy's list of highlighted 6 | characters, corporations and alliances. 7 | ''' 8 | # ********************************************************************** 9 | import logging 10 | 11 | import wx 12 | from wx.lib.mixins.listctrl import CheckListCtrlMixin, ListCtrlAutoWidthMixin 13 | 14 | import config 15 | import sortarray 16 | import statusmsg 17 | # cSpell Checker - Correct Words**************************************** 18 | # // cSpell:words russsian, ccp's, pyperclip, chkversion, clpbd, gui 19 | # ********************************************************************** 20 | Logger = logging.getLogger(__name__) 21 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 22 | 23 | 24 | class CheckListCtrl(wx.ListCtrl, CheckListCtrlMixin, ListCtrlAutoWidthMixin): 25 | 26 | def __init__(self, parent): 27 | wx.ListCtrl.__init__(self, parent, wx.ID_ANY, style=wx.LC_REPORT | 28 | wx.SUNKEN_BORDER) 29 | CheckListCtrlMixin.__init__(self) 30 | ListCtrlAutoWidthMixin.__init__(self) 31 | 32 | 33 | class HighlightDialog(wx.Frame): 34 | def __init__(self, parent, *args, **kwds): 35 | kwds["style"] = (kwds.get("style", 0) | wx.CAPTION | wx.CLIP_CHILDREN | 36 | wx.CLOSE_BOX | wx.FRAME_FLOAT_ON_PARENT | wx.RESIZE_BORDER) 37 | wx.Frame.__init__(self, parent, *args, **kwds) 38 | 39 | self.Font = self.Font.Scaled(config.OPTIONS_OBJECT.Get("FontScale", 1)) 40 | self.SetName("HighlightDialog") 41 | self.SetSize((400, 300)) 42 | 43 | self.highlightList = CheckListCtrl(self) 44 | self.highlightList.InsertColumn(0, 'Name', width=180) 45 | self.highlightList.InsertColumn(1, 'ID', width=0) 46 | self.highlightList.InsertColumn(2, 'Type') 47 | self.buttonPanel = wx.Panel(self, wx.ID_ANY) 48 | self.appBtn = wx.Button(self.buttonPanel, wx.ID_OK, "Delete Selected Entries") 49 | self.cnclBtn = wx.Button(self.buttonPanel, wx.ID_CANCEL, "Cancel Changes") 50 | 51 | self.Bind(wx.EVT_BUTTON, self.OnApply, id=self.appBtn.GetId()) 52 | self.Bind(wx.EVT_BUTTON, self.OnCancel, id=self.cnclBtn.GetId()) 53 | self.Bind(wx.EVT_CHAR_HOOK, self.OnHook) 54 | 55 | self.highlighted_entities = config.OPTIONS_OBJECT.Get("highlightedList", default=[]) 56 | self._populateList() 57 | 58 | self.__set_properties() 59 | self.__do_layout() 60 | 61 | if config.OPTIONS_OBJECT.Get("StayOnTop", True): 62 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP) 63 | self.ToggleWindowStyle(wx.STAY_ON_TOP) 64 | 65 | def __set_properties(self): 66 | self.SetTitle("Review Highlighted Entities") 67 | # Colour Scheme Dictionaries 68 | self.dark_dict = config.DARK_MODE 69 | self.normal_dict = config.NORMAL_MODE 70 | 71 | # Colour Scheme 72 | if not config.OPTIONS_OBJECT.Get("DarkMode", False): 73 | self.bg_colour = self.normal_dict["BG"] 74 | self.txt_colour = self.normal_dict["TXT"] 75 | self.lne_colour = self.normal_dict["LNE"] 76 | self.hl1_colour = self.normal_dict["HL1"] 77 | else: 78 | self.bg_colour = self.dark_dict["BG"] 79 | self.txt_colour = self.dark_dict["TXT"] 80 | self.lne_colour = self.dark_dict["LNE"] 81 | self.hl1_colour = self.dark_dict["HL1"] 82 | 83 | # Set default colors 84 | self.SetBackgroundColour(self.bg_colour) 85 | self.SetForegroundColour(self.txt_colour) 86 | self.highlightList.SetBackgroundColour(self.bg_colour) 87 | self.highlightList.SetForegroundColour(self.txt_colour) 88 | 89 | # Window icon 90 | icon = wx.Icon() 91 | icon.CopyFromBitmap(wx.Bitmap(config.ICON_FILE, wx.BITMAP_TYPE_ANY)) 92 | self.SetIcon(icon) 93 | 94 | def __do_layout(self): 95 | main = wx.BoxSizer(wx.VERTICAL) 96 | buttonSizer = wx.BoxSizer(wx.HORIZONTAL) 97 | instrLbl = wx.StaticText(self, wx.ID_ANY, "Select entities to be removed from highlight list:", style=wx.ALIGN_LEFT) 98 | main.Add(instrLbl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 10) 99 | main.Add(self.highlightList, 1, wx.ALL | wx.EXPAND, 10) 100 | buttonSizer.Add(self.appBtn, 1, wx.RIGHT, 5) 101 | buttonSizer.Add(self.cnclBtn, 1, wx.LEFT, 5) 102 | self.buttonPanel.SetSizer(buttonSizer) 103 | main.Add(self.buttonPanel, 0, wx.ALIGN_BOTTOM | wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 10) 104 | self.SetSizer(main) 105 | self.Layout() 106 | self.Centre() 107 | 108 | def _populateList(self): 109 | idx = 0 110 | if self.highlighted_entities == []: 111 | return 112 | if len(self.highlighted_entities) > 1: 113 | self.highlighted_entities = sortarray.sort_array(self.highlighted_entities, 2, 1) 114 | for i in self.highlighted_entities: 115 | index = self.highlightList.InsertItem(idx, i[1]) 116 | self.highlightList.SetItem(index, 1, str(i[0])) 117 | self.highlightList.SetItem(index, 2, i[2]) 118 | idx += 1 119 | 120 | def OnHook(self, event): 121 | if event.GetKeyCode() == wx.WXK_ESCAPE: 122 | self.OnCancel(event) 123 | if event.GetKeyCode() == wx.WXK_RETURN: 124 | self.OnApply(event) 125 | else: 126 | event.Skip() 127 | 128 | def OnApply(self, event): 129 | num = self.highlightList.GetItemCount() 130 | for i in range(num): 131 | if self.highlightList.IsChecked(i): 132 | id = int(self.highlightList.GetItemText(i, 1)) 133 | n = 0 134 | for r in self.highlighted_entities: 135 | if r[0] == id: 136 | del self.highlighted_entities[n] 137 | n += 1 138 | config.OPTIONS_OBJECT.Set("highlightedList", self.highlighted_entities) 139 | self.Parent.updateList(config.OPTIONS_OBJECT.Get("outlist")) 140 | self.Close() 141 | 142 | def OnCancel(self, event): 143 | if config.OPTIONS_OBJECT.Get("StayOnTop", True): 144 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP) 145 | self.Close() 146 | 147 | 148 | def showHighlightDialog(parent, evt=None): 149 | app = wx.App(False) 150 | frame = HighlightDialog(parent=parent) 151 | app.SetTopWindow(frame) 152 | frame.Show() 153 | app.MainLoop() 154 | -------------------------------------------------------------------------------- /ignoredialog.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' Dialog to view and remove entities from PySpy's list of ignored 6 | characters, corporations and alliances. 7 | ''' 8 | # ********************************************************************** 9 | import logging 10 | 11 | import wx 12 | from wx.lib.mixins.listctrl import CheckListCtrlMixin, ListCtrlAutoWidthMixin 13 | 14 | import config 15 | import sortarray 16 | import statusmsg 17 | # cSpell Checker - Correct Words**************************************** 18 | # // cSpell:words russsian, ccp's, pyperclip, chkversion, clpbd, gui 19 | # ********************************************************************** 20 | Logger = logging.getLogger(__name__) 21 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 22 | 23 | 24 | class CheckListCtrl(wx.ListCtrl, CheckListCtrlMixin, ListCtrlAutoWidthMixin): 25 | 26 | def __init__(self, parent): 27 | wx.ListCtrl.__init__(self, parent, wx.ID_ANY, style=wx.LC_REPORT | 28 | wx.SUNKEN_BORDER) 29 | CheckListCtrlMixin.__init__(self) 30 | ListCtrlAutoWidthMixin.__init__(self) 31 | 32 | 33 | class IgnoreDialog(wx.Frame): 34 | def __init__(self, parent, *args, **kwds): 35 | kwds["style"] = (kwds.get("style", 0) | wx.CAPTION | wx.CLIP_CHILDREN | 36 | wx.CLOSE_BOX | wx.FRAME_FLOAT_ON_PARENT | wx.RESIZE_BORDER) 37 | wx.Frame.__init__(self, parent, *args, **kwds) 38 | 39 | self.Font = self.Font.Scaled(config.OPTIONS_OBJECT.Get("FontScale", 1)) 40 | self.SetName("IgnoreDialog") 41 | self.SetSize((400, 300)) 42 | 43 | self.ignoredList = CheckListCtrl(self) 44 | self.ignoredList.InsertColumn(0, 'Name', width=180) 45 | self.ignoredList.InsertColumn(1, 'ID', width=0) 46 | self.ignoredList.InsertColumn(2, 'Type') 47 | self.buttonPanel = wx.Panel(self, wx.ID_ANY) 48 | self.appBtn = wx.Button(self.buttonPanel, wx.ID_OK, "Delete Selected Entries") 49 | self.cnclBtn = wx.Button(self.buttonPanel, wx.ID_CANCEL, "Cancel Changes") 50 | 51 | self.Bind(wx.EVT_BUTTON, self.OnApply, id=self.appBtn.GetId()) 52 | self.Bind(wx.EVT_BUTTON, self.OnCancel, id=self.cnclBtn.GetId()) 53 | self.Bind(wx.EVT_CHAR_HOOK, self.OnHook) 54 | 55 | self.ignored_entities = config.OPTIONS_OBJECT.Get("ignoredList", default=[]) 56 | self._populateList() 57 | 58 | self.__set_properties() 59 | self.__do_layout() 60 | 61 | if config.OPTIONS_OBJECT.Get("StayOnTop", True): 62 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP) 63 | self.ToggleWindowStyle(wx.STAY_ON_TOP) 64 | 65 | def __set_properties(self): 66 | self.SetTitle("Review Ignored Entities") 67 | # Colour Scheme Dictionaries 68 | self.dark_dict = config.DARK_MODE 69 | self.normal_dict = config.NORMAL_MODE 70 | 71 | # Colour Scheme 72 | if not config.OPTIONS_OBJECT.Get("DarkMode", False): 73 | self.bg_colour = self.normal_dict["BG"] 74 | self.txt_colour = self.normal_dict["TXT"] 75 | self.lne_colour = self.normal_dict["LNE"] 76 | self.hl1_colour = self.normal_dict["HL1"] 77 | else: 78 | self.bg_colour = self.dark_dict["BG"] 79 | self.txt_colour = self.dark_dict["TXT"] 80 | self.lne_colour = self.dark_dict["LNE"] 81 | self.hl1_colour = self.dark_dict["HL1"] 82 | 83 | # Set default colors 84 | self.SetBackgroundColour(self.bg_colour) 85 | self.SetForegroundColour(self.txt_colour) 86 | self.ignoredList.SetBackgroundColour(self.bg_colour) 87 | self.ignoredList.SetForegroundColour(self.txt_colour) 88 | 89 | # Window icon 90 | icon = wx.Icon() 91 | icon.CopyFromBitmap(wx.Bitmap(config.ICON_FILE, wx.BITMAP_TYPE_ANY)) 92 | self.SetIcon(icon) 93 | 94 | def __do_layout(self): 95 | main = wx.BoxSizer(wx.VERTICAL) 96 | buttonSizer = wx.BoxSizer(wx.HORIZONTAL) 97 | instrLbl = wx.StaticText(self, wx.ID_ANY, "Select entities to be removed from ignore list:", style=wx.ALIGN_LEFT) 98 | main.Add(instrLbl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 10) 99 | main.Add(self.ignoredList, 1, wx.ALL | wx.EXPAND, 10) 100 | buttonSizer.Add(self.appBtn, 1, wx.RIGHT, 5) 101 | buttonSizer.Add(self.cnclBtn, 1, wx.LEFT, 5) 102 | self.buttonPanel.SetSizer(buttonSizer) 103 | main.Add(self.buttonPanel, 0, wx.ALIGN_BOTTOM | wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 10) 104 | self.SetSizer(main) 105 | self.Layout() 106 | self.Centre() 107 | 108 | def _populateList(self): 109 | idx = 0 110 | if self.ignored_entities == []: 111 | return 112 | if len(self.ignored_entities) > 1: 113 | self.ignored_entities = sortarray.sort_array(self.ignored_entities, 2, 1) 114 | for i in self.ignored_entities: 115 | index = self.ignoredList.InsertItem(idx, i[1]) 116 | self.ignoredList.SetItem(index, 1, str(i[0])) 117 | self.ignoredList.SetItem(index, 2, i[2]) 118 | idx += 1 119 | 120 | def OnHook(self, event): 121 | if event.GetKeyCode() == wx.WXK_ESCAPE: 122 | self.OnCancel(event) 123 | if event.GetKeyCode() == wx.WXK_RETURN: 124 | self.OnApply(event) 125 | else: 126 | event.Skip() 127 | 128 | def OnApply(self, event): 129 | num = self.ignoredList.GetItemCount() 130 | for i in range(num): 131 | if self.ignoredList.IsChecked(i): 132 | id = int(self.ignoredList.GetItemText(i, 1)) 133 | n = 0 134 | for r in self.ignored_entities: 135 | if r[0] == id: 136 | del self.ignored_entities[n] 137 | n += 1 138 | config.OPTIONS_OBJECT.Set("ignoredList", self.ignored_entities) 139 | self.Parent.updateList(config.OPTIONS_OBJECT.Get("outlist")) 140 | self.Close() 141 | 142 | def OnCancel(self, event): 143 | if config.OPTIONS_OBJECT.Get("StayOnTop", True): 144 | self.Parent.ToggleWindowStyle(wx.STAY_ON_TOP) 145 | self.Close() 146 | 147 | 148 | def showIgnoreDialog(parent, evt=None): 149 | app = wx.App(False) 150 | frame = IgnoreDialog(parent=parent) 151 | app.SetTopWindow(frame) 152 | frame.Show() 153 | app.MainLoop() 154 | -------------------------------------------------------------------------------- /optstore.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' 6 | PresistentOptions can be used to create an object that stores values 7 | in a dictionary and save them in a pickle file for use in subsequent 8 | sessions. 9 | ''' 10 | # ********************************************************************** 11 | import logging 12 | import os 13 | import pickle 14 | 15 | import config 16 | # cSpell Checker - Correct Words**************************************** 17 | # // cSpell:words russsian 18 | # ********************************************************************** 19 | Logger = logging.getLogger(__name__) 20 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 21 | 22 | 23 | class PersistentOptions(): 24 | ''' 25 | :class:`PersistentOptions`: Store variables between sessions. 26 | 27 | Creates a dictionary object to store various variables in a 28 | pickle file between sessions. 29 | ''' 30 | def __init__(self, options_file): 31 | ''' 32 | :param `options_file`: the relative or absolute path to the pickle file; 33 | ''' 34 | self._pickle_file = options_file 35 | self._options = self._restore() 36 | 37 | def ListKeys(self): 38 | ''' 39 | Returns list of all keys available in class object. 40 | ''' 41 | keys = [] 42 | for key in self._options: 43 | keys.append(key) 44 | return keys 45 | 46 | def Get(self, key, default=None): 47 | ''' 48 | Returns the value of the specified key in the dictionary object. 49 | 50 | :param `key`: a valid key of the dictionary object; 51 | :param `default`: the value that should be returned if key is invalid; 52 | ''' 53 | try: 54 | return self._options[key] 55 | except KeyError: 56 | if default is not None: 57 | return default 58 | else: 59 | raise Exception("ERROR: no such key: " + str(key)) 60 | 61 | def Set(self, key, value): 62 | ''' 63 | Stores value under the specified key in the dictionary object. 64 | 65 | :param `key`: a new or existing key of the dictionary object; 66 | :param `value`: any python object; 67 | ''' 68 | self._options[key] = value 69 | 70 | def Del(self, key): 71 | ''' 72 | Deletes specified key in the dictionary object. 73 | 74 | :param `key`: existing key of the dictionary object; 75 | ''' 76 | try: 77 | del self._options[key] 78 | except: 79 | raise Exception("ERROR: no such key: " + str(key)) 80 | 81 | def Save(self): 82 | ''' 83 | Saves the dictionary object in a pickle file under the file name 84 | provided at instantiation. 85 | ''' 86 | self._storePickle(self._pickle_file, self._options) 87 | return 88 | 89 | def _restore(self): 90 | ''' 91 | Restores the dictionary object from the pickle file saved under 92 | the file name provided at instantiation. 93 | ''' 94 | return self._getPickle(self._pickle_file) 95 | 96 | def _storePickle(self, pickle_file, data): 97 | ''' 98 | Save binary pickle to disk. 99 | 100 | :param `pickle_file`: absolute or relative path to pickle file; 101 | :param `data`: any python object; 102 | ''' 103 | try: 104 | pickle_dir = os.path.dirname(pickle_file) 105 | except: 106 | pickle_dir = "./" 107 | pickle_data = data 108 | try: 109 | if not os.path.exists(pickle_dir): 110 | os.makedirs(pickle_dir) 111 | with open(pickle_file, 'wb') as file: 112 | pickle.dump(pickle_data, file, pickle.HIGHEST_PROTOCOL) 113 | except Exception: 114 | Logger.warn("Failed to create / store pickle file.", exc_info=True) 115 | finally: 116 | return 117 | 118 | def _getPickle(self, pickle_file): 119 | ''' 120 | Returns data contained in pickle file or if pickle_file is not a 121 | valid pickle, return empty dictionary. 122 | 123 | :param `pickle_file`: absolute or relative path to pickle file; 124 | ''' 125 | pickle_data = {} 126 | try: 127 | with open(pickle_file, 'rb') as file: 128 | pickle_data = pickle.load(file) 129 | return pickle_data 130 | except FileNotFoundError: 131 | Logger.warn("Could not find pickle file.", exc_info=True) 132 | finally: 133 | return pickle_data 134 | -------------------------------------------------------------------------------- /reportstats.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' Provides functionality to report basic usage statistics to a 6 | webserver. 7 | ''' 8 | # ********************************************************************** 9 | import json 10 | import logging 11 | import platform 12 | import threading 13 | import time 14 | 15 | import requests 16 | 17 | import config 18 | import statusmsg 19 | # cSpell Checker - Correct Words**************************************** 20 | # // cSpell:words russsian, blops 21 | # ********************************************************************** 22 | Logger = logging.getLogger(__name__) 23 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 24 | 25 | 26 | class ReportStats(threading.Thread): 27 | ''' 28 | Collects a number of properties and selected user options 29 | and uploads those each time the user performs a character analysis, 30 | together with the number of characters searched and the search duration. 31 | Instances of ReportStats run in their own separate thread and do not 32 | return any values, regardless of successful upload or failure. 33 | ''' 34 | # Report stats in separate thread. Do not complain if error encountered. 35 | def __init__(self, outlist, duration): 36 | super(ReportStats, self).__init__() 37 | self.daemon = True 38 | self._uuid = config.OPTIONS_OBJECT.Get("uuid") 39 | self._version = config.CURRENT_VER 40 | self._platform = platform.system() 41 | self._chars = str(len(outlist)) 42 | self._duration = str(duration) 43 | self._sh_faction = str(config.OPTIONS_OBJECT.Get("ShowFaction", True)) 44 | self._hl_blops = str(config.OPTIONS_OBJECT.Get("HlBlops", True)) 45 | self._ig_factions = str(not config.OPTIONS_OBJECT.Get("HlNone", True)) 46 | self._gui_alpha = config.OPTIONS_OBJECT.Get("GuiAlpha", 250) 47 | 48 | def run(self): 49 | url = "http://pyspy.pythonanywhere.com/add_record/" 50 | headers = { 51 | "Accept-Encoding": "gzip", 52 | "User-Agent": "PySpy, Author: White Russsian, https://github.com/WhiteRusssian/PySpy" 53 | } 54 | payload = { 55 | "uuid": self._uuid, 56 | "version": self._version, 57 | "platform": self._platform, 58 | "chars": self._chars, 59 | "duration": self._duration, 60 | "sh_faction": self._sh_faction, 61 | "hl_blops": self._hl_blops, 62 | "ig_factions": self._ig_factions, 63 | "gui_alpha": self._gui_alpha 64 | } 65 | 66 | try: 67 | r = requests.get(url, headers=headers, params=payload) 68 | except requests.exceptions.ConnectionError: 69 | Logger.info("No network connection.", exc_info=True) 70 | statusmsg.push_status( 71 | '''NETWORK ERROR: Check your internet connection 72 | and firewall settings.''' 73 | ) 74 | time.sleep(5) 75 | return 76 | 77 | if r.status_code != 200: 78 | status_code = r.status_code 79 | reason = r.reason 80 | Logger.info( 81 | "Could not upload usage statistics. Server message: '" + 82 | reason + "'. Code: " + str(status_code) + " [URL: " + r.url + "]", 83 | exc_info=True 84 | ) 85 | return 86 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wxPython>=4.0.3 2 | requests>=2.19.1 3 | pyperclip>=1.6.2 4 | -------------------------------------------------------------------------------- /sortarray.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | '''SortArray() provides functionality to sort an array (list of lists 6 | or tuples) containing string or numeric values (or a mix thereof). 7 | Ability to sort by two "columns", to break a tie in the primary sort 8 | column. Can be case sensitive. 9 | ''' 10 | # cSpell Checker - Correct Words**************************************** 11 | # // cSpell:words russsian 12 | # ********************************************************************** 13 | 14 | 15 | class SortArrayError(Exception): 16 | ''' 17 | Base exception for any exceptions raised by SortArray. 18 | ''' 19 | def __init__(self, msg=None): 20 | if msg is None: 21 | # Set some default useful error message 22 | msg = "SortArray encountered a problem and stopped." 23 | super(SortArrayError, self).__init__(msg) 24 | 25 | 26 | class OutOfBoundError(SortArrayError): 27 | ''' 28 | Gets raised when `prim_col` is greater than the last column in `array`. 29 | ''' 30 | def __init__(self, prim_col, sort_array): 31 | prim_col = prim_col 32 | array_columns = len(sort_array[0]) 33 | msg = ( 34 | "Sort column index (%s) exceeds number of columns in the unsorted input array (%s)." % (prim_col, array_columns) 35 | ) 36 | super(SortArrayError, self).__init__(msg) 37 | 38 | 39 | def _determineApproach(array, sort_col): 40 | ''' 41 | Takes an array (list of lists/tuples) and determines whether values 42 | in a given column should be sorted as numbers or strings. Returns 43 | a boolean which is True if column should be sorted as string and 44 | False if to be sorted as float. 45 | 46 | :param `array`: A list of list or tuples. 47 | 48 | :param `sort_col`: The number of the sort column as integer. 49 | 50 | :return: Boolean indicating if sort type is string (True) or float 51 | (False). 52 | ''' 53 | 54 | if sort_col > len(array[0]): 55 | raise OutOfBoundError(sort_col, array) 56 | 57 | # Check type of each element to be sorted_list 58 | is_num = is_str = is_other = is_none = 0 59 | for r in array: 60 | if isinstance(r[sort_col], (int, float)): 61 | is_num += 1 62 | elif isinstance(r[sort_col], str): 63 | try: 64 | float(r[sort_col]) 65 | is_num += 1 66 | except ValueError: 67 | is_str += 1 68 | elif r[sort_col] is None: 69 | is_none += 1 70 | else: 71 | is_other += 1 72 | 73 | # Make sure all elements are sortable, if not return TypeError 74 | if is_other > 0: 75 | raise TypeError 76 | 77 | # If any element is not or cannot be converted to a number, 78 | # treat all elements as string. 79 | elif is_str > 0: 80 | sort_as_str = True 81 | else: 82 | sort_as_str = False 83 | 84 | return sort_as_str 85 | 86 | 87 | def sort_array(array, prim_col, sec_col=None, prim_desc=False, sec_desc=False, case_sensitive=False): 88 | ''' 89 | Take an array (list of lists/tuples) with numeric or text values (or 90 | mix of both) and sort it by a primary column and optionally by a 91 | secondary column. It is tollerant to None values. 92 | 93 | :param `array`: List of lists or tuples containing strings 94 | or numbers. 95 | 96 | :param `prim_col`: Index (integer) of primary sort column. 97 | 98 | :param `sec_col`: Index (integer) of secondary sort column. 99 | 100 | :param `prim_desc`: Boolean indicating sort order of primary sort 101 | column(`True` for descending `False` for ascending) 102 | 103 | :param `sec_desc`: Boolean indicating sort order of secondary sort 104 | column(`True` for descending `False` for ascending) 105 | 106 | :param `case_sensitive`: Boolean set to True if cases matters. 107 | 108 | :return: The sorted array as list of tuples. 109 | ''' 110 | 111 | # Determine if we need to convert values to str or float 112 | prim_is_str = _determineApproach(array, prim_col) 113 | if sec_col is not None and sec_col != prim_col: 114 | sec_is_str = _determineApproach(array, sec_col) 115 | else: 116 | sec_col = None 117 | 118 | # We mke use of the fact that sorted() is stable and first sort by 119 | # the secondary sort column, if any, and then by the primary. 120 | 121 | # Secondary Sort -------------------------------------------------- 122 | if sec_col is not None: 123 | if sec_is_str and case_sensitive: 124 | sorted_array = sorted( 125 | array, 126 | key=lambda r: str(r[sec_col]) if r[sec_col] is not None else "", 127 | reverse=sec_desc 128 | ) 129 | elif sec_is_str and not case_sensitive: 130 | sorted_array = sorted( 131 | array, 132 | key=lambda r: str(r[sec_col]).lower() if r[sec_col] is not None else "", 133 | reverse=sec_desc 134 | ) 135 | else: 136 | sorted_array = sorted( 137 | array, 138 | key=lambda r: float(r[sec_col]) if r[sec_col] is not None else float("-inf"), 139 | reverse=sec_desc 140 | ) 141 | array = sorted_array 142 | 143 | # Primary Sort ----------------------------------------------------- 144 | if prim_is_str and case_sensitive: 145 | sorted_array = sorted( 146 | array, 147 | key=lambda r: str(r[prim_col]) if r[prim_col] is not None else "", 148 | reverse=prim_desc 149 | ) 150 | elif prim_is_str and not case_sensitive: 151 | sorted_array = sorted( 152 | array, 153 | key=lambda r: str(r[prim_col]).lower() if r[prim_col] is not None else "", 154 | reverse=prim_desc 155 | ) 156 | else: 157 | sorted_array = sorted( 158 | array, 159 | key=lambda r: float(r[prim_col]) if r[prim_col] is not None else float("-inf"), 160 | reverse=prim_desc 161 | ) 162 | 163 | return sorted_array 164 | -------------------------------------------------------------------------------- /statusmsg.py: -------------------------------------------------------------------------------- 1 | # !/usr/local/bin/python3.6 2 | # MIT licensed 3 | # Copyright (c) 2018 White Russsian 4 | # Github: ********************** 5 | ''' Provides functionality to update the status message in the 6 | wxPython GUi. 7 | ''' 8 | # ********************************************************************** 9 | import logging 10 | import time 11 | 12 | import wx 13 | 14 | import __main__ 15 | import config 16 | # cSpell Checker - Correct Words**************************************** 17 | # // cSpell:words russsian 18 | # ********************************************************************** 19 | Logger = logging.getLogger(__name__) 20 | # Example call: Logger.info("Something badhappened", exc_info=True) **** 21 | 22 | 23 | def push_status(msg): 24 | wx.CallAfter(__main__.app.PySpy.updateStatusbar, msg) 25 | --------------------------------------------------------------------------------