[
  {
    "path": ".coveragerc",
    "content": "[report]\nomit =\n    */venv/*\n    /usr/lib/libreoffice/program/uno.py\n"
  },
  {
    "path": ".dockerignore",
    "content": ".*\nvenv\nlog\nsaved_spreadsheets\nspreadsheets"
  },
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: http://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*]\nend_of_line = lf\ninsert_final_newline = true\n\n# Matches multiple files with brace expansion notation\n# Set default charset\n[*.{js,py}]\ncharset = utf-8\n\n# 4 space indentation\n[*.py]\nindent_style = space\nindent_size = 4\n\n# Tab indentation (no size specified)\n[Makefile]\nindent_style = tab\n\n# Indentation override for all JS under lib directory\n[*.{js,ts,tsx,scss}]\nindent_style = space\nindent_size = 2\n\n# Matches the exact files either package.json or .travis.yml\n[{package.json,.travis.yml}]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".flake8",
    "content": "# This is an example .flake8 config, used when developing *Black* itself.\n# Keep in sync with setup.cfg which is used for source packages.\n[flake8]\nignore = E203, E266, E501, W503, F403, F401, C901, E711, E712, E402\nmax-line-length = 79\nmax-complexity = 18\nselect = B,C,E,F,W,T4,B9"
  },
  {
    "path": ".gitignore",
    "content": "venv\n.#*\n\\#*\n*~\n__pycache__\n.~lock*\nspreadsheets/*\nlog/*.log\n*.pyc\nsaved_spreadsheets/*\n.coverage\nhtmlcov\n.vscode\n/todo.org\n"
  },
  {
    "path": "COPYING",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 2, June 1991\n\n Copyright (C) 1989, 1991 Free Software Foundation, Inc.,\n 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change free\nsoftware--to make sure the software is free for all its users.  This\nGeneral Public License applies to most of the Free Software\nFoundation's software and to any other program whose authors commit to\nusing it.  (Some other Free Software Foundation software is covered by\nthe GNU Lesser General Public License instead.)  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthis service if you wish), that you receive source code or can get it\nif you want it, that you can change the software or use pieces of it\nin new free programs; and that you know you can do these things.\n\n  To protect your rights, we need to make restrictions that forbid\nanyone to deny you these rights or to ask you to surrender the rights.\nThese restrictions translate to certain responsibilities for you if you\ndistribute copies of the software, or if you modify it.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must give the recipients all the rights that\nyou have.  You must make sure that they, too, receive or can get the\nsource code.  And you must show them these terms so they know their\nrights.\n\n  We protect your rights with two steps: (1) copyright the software, and\n(2) offer you this license which gives you legal permission to copy,\ndistribute and/or modify the software.\n\n  Also, for each author's protection and ours, we want to make certain\nthat everyone understands that there is no warranty for this free\nsoftware.  If the software is modified by someone else and passed on, we\nwant its recipients to know that what they have is not the original, so\nthat any problems introduced by others will not reflect on the original\nauthors' reputations.\n\n  Finally, any free program is threatened constantly by software\npatents.  We wish to avoid the danger that redistributors of a free\nprogram will individually obtain patent licenses, in effect making the\nprogram proprietary.  To prevent this, we have made it clear that any\npatent must be licensed for everyone's free use or not licensed at all.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                    GNU GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License applies to any program or other work which contains\na notice placed by the copyright holder saying it may be distributed\nunder the terms of this General Public License.  The \"Program\", below,\nrefers to any such program or work, and a \"work based on the Program\"\nmeans either the Program or any derivative work under copyright law:\nthat is to say, a work containing the Program or a portion of it,\neither verbatim or with modifications and/or translated into another\nlanguage.  (Hereinafter, translation is included without limitation in\nthe term \"modification\".)  Each licensee is addressed as \"you\".\n\nActivities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning the Program is not restricted, and the output from the Program\nis covered only if its contents constitute a work based on the\nProgram (independent of having been made by running the Program).\nWhether that is true depends on what the Program does.\n\n  1. You may copy and distribute verbatim copies of the Program's\nsource code as you receive it, in any medium, provided that you\nconspicuously and appropriately publish on each copy an appropriate\ncopyright notice and disclaimer of warranty; keep intact all the\nnotices that refer to this License and to the absence of any warranty;\nand give any other recipients of the Program a copy of this License\nalong with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and\nyou may at your option offer warranty protection in exchange for a fee.\n\n  2. You may modify your copy or copies of the Program or any portion\nof it, thus forming a work based on the Program, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) You must cause the modified files to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in\n    whole or in part contains or is derived from the Program or any\n    part thereof, to be licensed as a whole at no charge to all third\n    parties under the terms of this License.\n\n    c) If the modified program normally reads commands interactively\n    when run, you must cause it, when started running for such\n    interactive use in the most ordinary way, to print or display an\n    announcement including an appropriate copyright notice and a\n    notice that there is no warranty (or else, saying that you provide\n    a warranty) and that users may redistribute the program under\n    these conditions, and telling the user how to view a copy of this\n    License.  (Exception: if the Program itself is interactive but\n    does not normally print such an announcement, your work based on\n    the Program is not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Program,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Program, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Program.\n\nIn addition, mere aggregation of another work not based on the Program\nwith the Program (or with a work based on the Program) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may copy and distribute the Program (or a work based on it,\nunder Section 2) in object code or executable form under the terms of\nSections 1 and 2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable\n    source code, which must be distributed under the terms of Sections\n    1 and 2 above on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three\n    years, to give any third party, for a charge no more than your\n    cost of physically performing source distribution, a complete\n    machine-readable copy of the corresponding source code, to be\n    distributed under the terms of Sections 1 and 2 above on a medium\n    customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer\n    to distribute corresponding source code.  (This alternative is\n    allowed only for noncommercial distribution and only if you\n    received the program in object code or executable form with such\n    an offer, in accord with Subsection b above.)\n\nThe source code for a work means the preferred form of the work for\nmaking modifications to it.  For an executable work, complete source\ncode means all the source code for all modules it contains, plus any\nassociated interface definition files, plus the scripts used to\ncontrol compilation and installation of the executable.  However, as a\nspecial exception, the source code distributed need not include\nanything that is normally distributed (in either source or binary\nform) with the major components (compiler, kernel, and so on) of the\noperating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering\naccess to copy from a designated place, then offering equivalent\naccess to copy the source code from the same place counts as\ndistribution of the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  4. You may not copy, modify, sublicense, or distribute the Program\nexcept as expressly provided under this License.  Any attempt\notherwise to copy, modify, sublicense or distribute the Program is\nvoid, and will automatically terminate your rights under this License.\nHowever, parties who have received copies, or rights, from you under\nthis License will not have their licenses terminated so long as such\nparties remain in full compliance.\n\n  5. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Program or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Program (or any work based on the\nProgram), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n  6. Each time you redistribute the Program (or any work based on the\nProgram), the recipient automatically receives a license from the\noriginal licensor to copy, distribute or modify the Program subject to\nthese terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties to\nthis License.\n\n  7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Program at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Program by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under\nany particular circumstance, the balance of the section is intended to\napply and the section as a whole is intended to apply in other\ncircumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system, which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  8. If the distribution and/or use of the Program is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Program under this License\nmay add an explicit geographical distribution limitation excluding\nthose countries, so that distribution is permitted only in or among\ncountries not thus excluded.  In such case, this License incorporates\nthe limitation as if written in the body of this License.\n\n  9. The Free Software Foundation may publish revised and/or new versions\nof the General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any\nlater version\", you have the option of following the terms and conditions\neither of that version or of any later version published by the Free\nSoftware Foundation.  If the Program does not specify a version number of\nthis License, you may choose any version ever published by the Free Software\nFoundation.\n\n  10. If you wish to incorporate parts of the Program into other free\nprograms whose distribution conditions are different, write to the author\nto ask for permission.  For software which is copyrighted by the Free\nSoftware Foundation, write to the Free Software Foundation; we sometimes\nmake exceptions for this.  Our decision will be guided by the two goals\nof preserving the free status of all derivatives of our free software and\nof promoting the sharing and reuse of software generally.\n\n                            NO WARRANTY\n\n  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY\nFOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN\nOTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED\nOR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS\nTO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE\nPROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,\nREPAIR OR CORRECTION.\n\n  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR\nREDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING\nOUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED\nTO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY\nYOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER\nPROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software; you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation; either version 2 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this\nwhen it starts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author\n    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may\nbe called something other than `show w' and `show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n  `Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n  <signature of Ty Coon>, 1 April 1989\n  Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM debian:sid-slim\n\nRUN apt-get update && \\\n    apt-get upgrade -y && \\\n    apt-get install -y --no-install-recommends wget python3 python3-dev python3-pip python3-virtualenv python3-uno libreoffice-calc && \\\n    rm -rf /var/cache/apt/archives /var/lib/apt/lists/*\n\nCOPY * /spreadsheet_server/\nWORKDIR /spreadsheet_server\n\nRUN mkdir -p /spreadsheet_server/log\nRUN virtualenv --system-site-packages -p python3 /opt/virtualenv\n\nENV PATH=\"/opt/virtualenv/bin:$PATH\"\nRUN pip3 install --no-cache-dir -r requirements.txt\n\nCMD [\"python3\", \"-c\", \"from server import SpreadsheetServer; spreadsheet_server = SpreadsheetServer(host='0.0.0.0'); spreadsheet_server.run()\"]"
  },
  {
    "path": "README.md",
    "content": "# spreadsheet_server\n\n## Introduction\n\nspreadsheet_server was built to aid rapid web tool development where the logic\nwas already implemented in Microsoft Excel/LibreOffice Calc. Instead of\nrewriting the logic from scratch, this tool was born.\n\nThe tool has been developed to work on a headless GNU/Linux where the server\nand client are on the same machine.\n\n## Features\n\n- 'Instant' access to cells in the spreadsheets as they open in LibreOffice Calc.\n- All the function calculation support and power of LibreOffice Calc.\n- A given spreadsheet is locked (within python, not on disk) when it is accessed to prevent state irregularities across multiple concurrent connections to the same spreadsheet.\n- Monitoring of a directory with automatic loading and unloading of spreadsheets.\n- By default, when a spreadsheet file changes on disk, it will be closed and\n  opened in LibreOffice.\n- Spreadsheets can be saved - useful for debugging purposes.\n\n## Installation\n\n### Docker\n\n```\ndocker build . --tag=\"spreadsheet_server\"\ndocker run -v ./spreadsheets:/spreadsheet_server/spreadsheets -p 127.0.0.1:5555:5555 --name spreadsheet_server spreadsheet_server\n```\n\n### Ubuntu Server 20.04\n\n```\nsudo apt-get update && sudo apt-get upgrade\nsudo apt-get install git gcc python3 python3-dev python3-virtualenv libreoffice-calc python3-uno\ngit clone https://github.com/robsco-git/spreadsheet_server.git\ncd spreadsheet_server\nmkdir -p ~/.virtualenvs\nvirtualenv --system-site-packages -p python3 ~/.virtualenvs/spreadsheet_server\nsource ~/.virtualenvs/spreadsheet_server/bin/activate\npip install -r requirements.txt\npython server.py\n# Copy spreadsheets (.xlsx, .ods etc) into ./spreadsheets\n```\n\n## Usage\n\nPlace your spreadsheets in './spreadsheets'.\n\nMake sure you have a correctly set up virtualenv the required packages are installed\n(see below), then run the server:\n\n```\npython server.py\n```\n\nYou can also create a SpreadsheetServer object (from server.py) and then call\nthe method 'run' on the object to start the server. This allows the for\ncustomisation of the default settings. Have a look in server.py to see what can\nbe set.\n\nexample_client.py is provided for an overview of how to use the functions\nexposed by the client. This is a good place to start.\n\n## How it works\n\n- A LibreOffice instance is launched by 'server.py' in a headless state.\n- By default, the './spreadsheets' directory is polled every 5 seconds for file\n  changes.\n- New spreadsheets in the directory are opened with LibreOffice and removed\n  spreadsheets are closed in LibreOffice.\n- The 'client.py' connects to the server and can update cells and retrieve\n  their calculated content.\n\n## Questions\n\nWhat is UNO? What is Python-UNO? What is PyOO?\nThe first few paragraphs of the PyOO README should answer most of your questions:\nhttps://github.com/seznam/pyoo/blob/master/README.rst\n\n## Notes\n\n### Symbolic links and lock files\n\nIf you symbolically link a spreadsheet itself, the lock files that LibreOffice\ncreates and uses are stored in the directory where the file is located, not in the\ndirectory where the symbolic link is located. It is recommended that you place your\nspreadsheet(s) into a directory and symbolically link that directory into the\n'./spreadsheets' directory. This way, LibreOffice will always be able to locate the\nlock files it needs. You can use a directory per project if you like.\n\n## Tests\n\nYou can run the all the current tests with 'python -m unittest discover'.\nUse './coverage.sh' to run the coverage analysis of the current tests and have a look\nin the generated htmlcov directory. You will need the 'coverage' installed to the\nvirtualenv:\n\n```\npip install coverage\n```\n"
  },
  {
    "path": "client.py",
    "content": "# Copyright (C) 2016 Robert Scott\n\n# This program is free software; you can redistribute it and/or\n# modify it under the terms of the GNU General Public License\n# as published by the Free Software Foundation; either version 2\n# of the License, or (at your option) any later version.\n\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n\n# You should have received a copy of the GNU General Public License\n# along with this program; if not, write to the Free Software\n# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.\n\nimport socket\nimport json\nimport traceback\nimport struct\nimport select\n\nIP, PORT = \"localhost\", 5555\n\nTIMEOUT = 10\n\n\nclass SpreadsheetClient:\n    def __init__(self, spreadsheet, ip=IP, port=PORT):\n        try:\n            self.sock = self.__connect(ip, port)\n        except socket.error:\n            raise RuntimeError(\"Could not connect to the server.\")\n        else:\n            self.__set_spreadsheet(spreadsheet)\n\n    def __connect(self, ip, port):\n        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        sock.connect((ip, port))\n        return sock\n\n    def __set_spreadsheet(self, spreadsheet):\n        self.__send([\"SPREADSHEET\", spreadsheet])\n        received = self.__receive()\n\n        if received == \"NOT FOUND\" or received != \"OK\":\n            self.disconnect()\n            raise RuntimeError(\"The requested spreadsheet was not found.\")\n\n    def set_cells(self, sheet, cell_ref, data):\n        \"\"\"Set the value(s) for a single cell or a cell range.\n\n        'sheet' is either a 0-based index or the string name of the sheet.\n        'cell_ref' is a LibreOffice style cell reference. eg. \"A1\" or \"D7:G42\".\n\n        For a single cell, 'data' is a single string, int or float value.\n\n        For a one dimensional (only horizontal or only vertical) range of\n        cells, 'data' is a list. For a two dimensional range of cells, 'data'\n        is a list of lists. For example setting the 'cell_ref' \"A1:C3\"\n        requires 'data' of the format:\n        [[A1, B1, C1], [A2, B2, C2], [A3, B3, C3]].\n        \"\"\"\n\n        self.__send([\"SET\", sheet, cell_ref, data])\n\n        received = self.__receive()\n        if type(received) == dict:\n            # The server is retuning an error\n            raise RuntimeError(received[\"ERROR\"])\n\n    def get_sheet_names(self):\n        \"\"\"Returns a list of all sheet names in the workbook.\"\"\"\n\n        self.__send([\"GET_SHEETS\"])\n        sheet_names = self.__receive()\n\n        if sheet_names == \"ERROR\":\n            raise Exception(\"Could not retrieve sheet names.\")\n\n        return sheet_names\n\n    def get_cells(self, sheet, cell_ref):\n        \"\"\"Get the value of a single cell or a cell range from the server\n        and return it or them.\n\n        'sheet' is either a 0-based index or the string name of the sheet.\n        'cell_ref' is what one would use in LibreOffice Calc. Eg. \"ABC945\" or\n        \"A3:F75\".\n\n        A single cell is returned for a single value.\n        A list of cell values is returned for a one dimensional range of cells.\n        A list of lists is returned for a two dimensional range of cells.\n        \"\"\"\n\n        self.__send([\"GET\", sheet, cell_ref])\n        cells = self.__receive()\n\n        if type(cells) == dict:\n            # The server is retuning an error\n            raise RuntimeError(cells[\"ERROR\"])\n\n        return cells\n\n    def save_spreadsheet(self, filename):\n        \"\"\"Save the spreadsheet in its current state on the server. The\n        server determines where it is saved.\"\"\"\n\n        self.__send([\"SAVE\", filename])\n        return self.__receive()\n\n    def __send(self, msg):\n        \"\"\"Encode msg into json and then send it over the socket.\"\"\"\n\n        json_msg = json.dumps(msg)\n        json_msg = bytes(json_msg, \"utf-8\")\n\n        # Prepend the length of the string to the meg\n        json_msg = struct.pack(\">I\", len(json_msg)) + json_msg\n\n        try:\n            self.sock.sendall(json_msg)\n        except:  # noqa\n            traceback.print_exc()\n            raise Exception(\"Could not send message to server\")\n\n    def __receive(self):\n        \"\"\"Receive a message from the client, convert the received utf-8\n        bytes into a string then decode if from json.\"\"\"\n\n        raw_msg_length = self.__receive_length(4)\n        if not raw_msg_length:\n            return False\n        msg_length = struct.unpack(\">I\", raw_msg_length)[0]\n\n        recv = self.__receive_length(msg_length)\n\n        if recv == b\"\":\n            # The connection has been closed.\n            raise Exception(\"Connection to server closed!\")\n\n        received = str(recv, encoding=\"utf-8\")\n        received = json.loads(received)\n\n        return received\n\n    def __receive_length(self, length):\n        \"\"\"Receive length number of bytes from the client.\"\"\"\n\n        data = b\"\"\n        while len(data) < length:\n\n            ready = select.select([self.sock], [], [], TIMEOUT)\n\n            if ready[0]:\n\n                packet = self.sock.recv(length - len(data))\n                if not packet:\n                    return b\"\"\n                data += packet\n\n            else:\n                # Did not recieve on the socket within the timeout\n                return b\"\"\n\n        return data\n\n    def disconnect(self):\n        \"\"\"Disconnect from the server.\"\"\"\n\n        try:\n            self.sock.shutdown(socket.SHUT_RDWR)\n        except socket.error:\n            # The client has already disconnected\n            pass\n\n        self.sock.close()\n"
  },
  {
    "path": "connection.py",
    "content": "# Copyright (C) 2016 Robert Scott\n\n# This program is free software; you can redistribute it and/or\n# modify it under the terms of the GNU General Public License\n# as published by the Free Software Foundation; either version 2\n# of the License, or (at your option) any later version.\n\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n\n# You should have received a copy of the GNU General Public License\n# along with this program; if not, write to the Free Software\n# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,\n# USA.\n\nimport logging\nfrom math import pow\nfrom werkzeug.utils import secure_filename\nfrom threading import ThreadError\nimport os\n\n\nCELL_REF_ERROR_STR = \"Cell range is invalid.\"\n\n\nclass SpreadsheetConnection:\n    \"\"\"Handles connections to the spreadsheets opened by soffice (LibreOffice).\n    \"\"\"\n\n    def __init__(self, spreadsheet, lock, save_path):\n        self.spreadsheet = spreadsheet\n        self.lock = lock\n        self.save_path = save_path\n\n    def lock_spreadsheet(self):\n        \"\"\"Lock the spreadsheet.\n\n        The getting and setting cell functions rely on a given spreadsheet\n        being locked. This insures simulations requests to the same spreadsheet\n        do not interfere with one another.\n        \"\"\"\n\n        self.lock.acquire()\n\n    def unlock_spreadsheet(self):\n        \"\"\" Unlock the spreadsheet and return a 'success' boolean.\"\"\"\n\n        try:\n            self.lock.release()\n            return True\n        except (RuntimeError, ThreadError):\n            return False\n\n    def __get_xy_index(self, cell_ref):\n        chars = [c for c in cell_ref if c.isalpha()]\n        nums = [n for n in cell_ref if n.isdigit()]\n\n        alpha_index = 0\n        for i, c in enumerate(chars):\n            # The base index for this character is in the range of 0 to 25\n            c_index = ord(c.upper()) - 65\n\n            if i == len(chars) - 1:\n                # The simple case of the least significant character.\n                # Eg. The 'J' in 'AMJ'.\n                alpha_index += c_index\n\n            else:\n                # The index for additional characters to the left are\n                # calculated c_index * 26^(n) where n is the characters\n                # position to the left.\n\n                # Need to increment c_index for correct multiplication\n                c_index += 1\n                alpha_index += c_index * pow(26, len(chars) - i - 1)\n\n        num_index = int(\"\".join(nums)) - 1  # zero-based\n        alpha_index = int(alpha_index)\n\n        # Check max values\n        # Column can not be > AMJ == 1023\n        if alpha_index >= 1024:\n            raise ValueError(CELL_REF_ERROR_STR)\n\n        # Row can not be > 1048576\n        if num_index >= 1048576:\n            raise ValueError(CELL_REF_ERROR_STR)\n\n        return alpha_index, num_index\n\n    def __is_single_cell(self, cell_ref):\n        if len(cell_ref.split(\":\")) == 1:\n            return True\n        return False\n\n    def __check_single_cell(self, cell_ref):\n        if not self.__is_single_cell(cell_ref):\n            raise ValueError(\n                \"Expected a single cell reference. A cell range was given.\"\n            )\n\n    def __cell_to_index(self, cell_ref):\n        \"\"\"Convert a spreadsheet style single cell or cell reference, to a zero\n        based numerical index.\n\n        'cell_ref' is what one would use in LibreOffice Calc. Eg. \"ABC945\".\n\n        Returned is: {\"row_index\": int, \"column_index\": int}.\n        \"\"\"\n\n        alpha_index, num_index = self.__get_xy_index(cell_ref)\n        return {\"row_index\": num_index, \"column_index\": alpha_index}\n\n    def __cell_range_to_index(self, cell_ref):\n        \"\"\"Convert a spreadsheet style range reference to zero-based numerical\n        indecies that describe the start and end points of the cell range.\n\n        'cell_ref' is what one would use in LibreOffice Calc. Eg. \"A1\" or\n        \"A1:D6\".\n\n        Returned is: {\"row_start\": int, \"row_end\": int,\n        \"column_start\": int, \"column_end\": int}\n        \"\"\"\n\n        left_ref, right_ref = cell_ref.split(\":\")\n\n        left_alpha_index, left_num_index = self.__get_xy_index(left_ref)\n        right_alpha_index, right_num_index = self.__get_xy_index(right_ref)\n\n        return {\n            \"row_start\": left_num_index,\n            \"row_end\": right_num_index,\n            \"column_start\": left_alpha_index,\n            \"column_end\": right_alpha_index,\n        }\n\n    def __check_for_lock(self):\n        if not self.lock.locked():\n            raise RuntimeError(\n                \"Lock for this spreadsheet has not been aquired.\"\n            )\n\n    def __convert_to_float_if_numeric(self, value):\n        \"\"\"If value is a string representation of a number, convert it to a\n        float. Otherwise, simply return the string.\n        \"\"\"\n\n        try:\n            return float(value)\n        except (ValueError, TypeError):\n            return value\n\n    def __check_list(self, data):\n        if not isinstance(data, list):\n            raise ValueError(\"Expecting list type.\")\n\n    def __check_1D_list(self, data):\n        self.__check_list(data)\n        if isinstance(data[0], list):\n            raise ValueError(\"Got 2D list when expecting 1D list.\")\n\n        for x, cell in enumerate(data):\n            data[x] = self.__convert_to_float_if_numeric(cell)\n\n        return data\n\n    def set_cells(self, sheet, cell_ref, value):\n        \"\"\"Set the value(s) for a single cell or a cell range. This can be used\n        when it is not known if 'cell_ref' refers to a single cell or a range\n\n        See 'set_cell' and 'set_cell_range' for more information.\n        \"\"\"\n\n        self.__validate_sheet_name(sheet)\n        self.__validate_cell_ref(cell_ref)\n\n        if self.__is_single_cell(cell_ref):\n            self.set_cell(sheet, cell_ref, value)\n        else:\n            self.set_cell_range(sheet, cell_ref, value)\n\n    def set_cell(self, sheet, cell_ref, value):\n        \"\"\"Set the value of a single cell.\n\n        'sheet' is either a 0-based index or the string name of the sheet.\n        'cell_ref' is a LibreOffice style cell reference. eg. \"A1\".\n        'value' is a single string, int or float value.\n        \"\"\"\n\n        self.__check_single_cell(cell_ref)\n\n        self.__check_for_lock()\n\n        r = self.__cell_to_index(cell_ref)\n        sheet = self.spreadsheet.sheets[sheet]\n\n        if isinstance(value, list):\n            raise ValueError(\n                \"Expectin a single cell. \\\n            A list of cells was given.\"\n            )\n\n        value = self.__convert_to_float_if_numeric(value)\n        sheet[r[\"row_index\"], r[\"column_index\"]].value = value\n\n    def set_cell_range(self, sheet, cell_ref, data):\n        \"\"\"Set the values for a cell range.\n\n        'sheet' is either a 0-based index or the string name of the sheet.\n        'cell_ref' is a LibreOffice style cell reference. eg. \"D7:G42\".\n\n        For a one dimensional (only horizontal or only vertical) range of\n        cells, 'data' is a list. For a two dimensional range of cells, 'data'\n        is a list of lists. For example setting the 'cell_ref' \"A1:C3\"\n        requires 'data' of the format:\n        [[A1, B1, C1], [A2, B2, C2], [A3, B3, C3]].\n        \"\"\"\n\n        self.__check_for_lock()\n\n        r = self.__cell_range_to_index(cell_ref)\n        sheet = self.spreadsheet.sheets[sheet]\n\n        if r[\"row_start\"] == r[\"row_end\"]:  # A row of cells\n            data = self.__check_1D_list(data)\n            sheet[\n                r[\"row_start\"], r[\"column_start\"] : r[\"column_end\"] + 1\n            ].values = data\n\n        elif r[\"column_start\"] == r[\"column_end\"]:  # A column of cells\n            data = self.__check_1D_list(data)\n            sheet[\n                r[\"row_start\"] : r[\"row_end\"] + 1, r[\"column_start\"]\n            ].values = data\n\n        else:  # A grid of cells\n            self.__check_list(data)\n            for x, row in enumerate(data):\n                if not isinstance(row, list):\n                    raise ValueError(\"Expected a list of cells.\")\n\n                for y, cell in enumerate(row):\n                    data[x][y] = self.__convert_to_float_if_numeric(cell)\n\n            sheet[\n                r[\"row_start\"] : r[\"row_end\"] + 1,\n                r[\"column_start\"] : r[\"column_end\"] + 1,\n            ].values = data\n\n    def get_sheet_names(self):\n        \"\"\"Returns a list of all sheet names in the workbook.\"\"\"\n\n        return [s.name for s in self.spreadsheet.sheets]\n\n    def __validate_cell_ref(self, cell_ref):\n        \"\"\" A cell ref must be of the LibreOffice format\n        e.g. A1 or A1:ABC123.\"\"\"\n\n        if type(cell_ref) is not str:\n            raise ValueError(CELL_REF_ERROR_STR)\n\n        if not cell_ref[0].isalpha():\n            raise ValueError(CELL_REF_ERROR_STR)\n\n        if not cell_ref[-1].isdigit():\n            raise ValueError(CELL_REF_ERROR_STR)\n\n        if \":\" in cell_ref:\n            # Check the second alpha if it exists\n            if not cell_ref[cell_ref.index(\":\") + 1].isalpha():\n                raise ValueError(CELL_REF_ERROR_STR)\n\n            # Check the start of the range has a numeric component\n            if not cell_ref[cell_ref.index(\":\") - 1].isdigit():\n                raise ValueError(CELL_REF_ERROR_STR)\n\n        # Check for any unallowed characters\n        for ref in cell_ref:\n            if not ref.isdigit() and not ref.isalpha() and ref != \":\":\n                raise ValueError(CELL_REF_ERROR_STR)\n\n        # TODO - Check range for sanity\n        # Reversed ranges should be allowed, they just need to be flipped\n        # e.g. \"A5:A1\" must become \"A1:A5\"\n        # Also need to convert \"A1:A1\" to \"A1\"\n\n    def __validate_sheet_name(self, sheet):\n        \"\"\"Don't want to send an invalid sheet to pyoo.\"\"\"\n\n        ERROR_STR = \"Sheet name is invalid.\"\n\n        sheet_names = self.get_sheet_names()\n        if type(sheet) is int:\n\n            if sheet < 0 or sheet > len(sheet_names) - 1:\n                raise ValueError(ERROR_STR)\n\n        elif type(sheet) is str:\n            if sheet not in sheet_names:\n                raise ValueError(ERROR_STR)\n\n        else:\n            raise ValueError(ERROR_STR)\n\n    def get_cells(self, sheet, cell_ref):\n        \"\"\"Gets the value(s) of a single cell or a cell range. This can be used\n        when it is not known if 'cell_ref' refers to a single cell or a range.\n\n        See 'get_cell' and 'get_cell_range' for more information.\n        \"\"\"\n\n        self.__validate_sheet_name(sheet)\n        self.__validate_cell_ref(cell_ref)\n\n        if self.__is_single_cell(cell_ref):\n            return self.get_cell(sheet, cell_ref)\n        else:\n            return self.get_cell_range(sheet, cell_ref)\n\n    def get_cell(self, sheet, cell_ref):\n        \"\"\"Returns the value of a single cell.\n\n        'sheet' is either a 0-based index or the string name of the sheet.\n        'cell_ref' is what one would use in LibreOffice Calc. Eg. \"A3\".\n\n        A single cell value is returned.\n        \"\"\"\n\n        self.__check_single_cell(cell_ref)\n\n        r = self.__cell_to_index(cell_ref)\n        sheet = self.spreadsheet.sheets[sheet]\n\n        return sheet[r[\"row_index\"], r[\"column_index\"]].value\n\n    def get_cell_range(self, sheet, cell_ref):\n        \"\"\"Returns the values of a range of cells.\n\n        'sheet' is either a 0-based index or the string name of the sheet.\n        'cell_ref' is what one would use in LibreOffice Calc. Eg. \"A3:F75\".\n\n        A list of cell values is returned for a one dimensional range of cells.\n        A list of lists is returned for a two dimensional range of cells.\n        \"\"\"\n\n        r = self.__cell_range_to_index(cell_ref)\n        sheet = self.spreadsheet.sheets[sheet]\n\n        logging.debug(\"Requested cell area: \" + str(r))\n\n        # Cell ranges are requested as: [vertical area, horizontal area]\n\n        if r[\"row_start\"] == r[\"row_end\"]:  # A row of cells was requested\n            return sheet[\n                r[\"row_start\"], r[\"column_start\"] : r[\"column_end\"] + 1\n            ].values\n\n        elif r[\"column_start\"] == r[\"column_end\"]:  # A column of cells\n            return sheet[\n                r[\"row_start\"] : r[\"row_end\"] + 1, r[\"column_start\"]\n            ].values\n\n        else:  # A grid of cells\n            return sheet[\n                r[\"row_start\"] : r[\"row_end\"] + 1,\n                r[\"column_start\"] : r[\"column_end\"] + 1,\n            ].values\n\n    def save_spreadsheet(self, filename):\n        \"\"\"Save the spreadsheet in it's current state.\n\n        'filename' is the name of the file.\n        \"\"\"\n\n        if self.lock.locked():\n            filename = secure_filename(filename)\n            self.spreadsheet.save(os.path.join(self.save_path, filename))\n            return True\n        else:\n            return False\n"
  },
  {
    "path": "coverage.sh",
    "content": "#!/bin/bash\n\n# Run the coverage tests and genereate the html reports\n\n#coverage run -m tests.test_connection && coverage html\ncoverage run -m unittest discover && coverage html\n"
  },
  {
    "path": "docker_run.sh",
    "content": "#!/bin/bash\nsudo docker build . -t spreadsheet_server\nsudo docker stop spreadsheet_server\nsudo docker rm spreadsheet_server\nsudo docker run -d \\\n    -v /home/robsco/code/spreadsheet_server/spreadsheets:/spreadsheet_server/spreadsheets \\\n    -v /home/robsco/code/spreadsheet_server/saved_spreadsheets:/spreadsheet_server/saved_spreadsheets \\\n    --restart always \\\n    -v /home/robsco/code/spreadsheet_server/log:/spreadsheet_server/log \\\n    -p 5555:5555 \\\n    --name spreadsheet_server \\\n    spreadsheet_server\n"
  },
  {
    "path": "example_client.py",
    "content": "# Copyright (C) 2016 Robert Scott\n\n# This program is free software; you can redistribute it and/or\n# modify it under the terms of the GNU General Public License\n# as published by the Free Software Foundation; either version 2\n# of the License, or (at your option) any later version.\n\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n\n# You should have received a copy of the GNU General Public License\n# along with this program; if not, write to the Free Software\n# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.\n\nimport shutil\nimport os\nfrom client import SpreadsheetClient\n\nif __name__ == \"__main__\":\n    \"\"\"This script shows how the differnet functions exposed by client.py can be\n    used.\"\"\"\n\n    EXAMPLE_SPREADSHEET = \"example.ods\"\n\n    # Copy the example spreadsheet from the tests directory into the spreadsheets\n    # directory\n\n    shutil.copyfile(\n        os.path.join(\"tests\", EXAMPLE_SPREADSHEET),\n        os.path.join(\"spreadsheets\", EXAMPLE_SPREADSHEET),\n    )\n\n    SHEET_NAME = \"Sheet1\"\n\n    print(\n        \"Waiting for the example spreadsheet to be scanned and loaded into LibreOffice.\"\n    )\n\n    sc = SpreadsheetClient(EXAMPLE_SPREADSHEET)\n\n    # Get sheet names\n    sheet_names = sc.get_sheet_names()\n    print(sheet_names)\n\n    # Set a cell value\n    sc.set_cells(SHEET_NAME, \"A1\", 5)\n\n    # Retrieve a cell value.\n    cell_value = sc.get_cells(SHEET_NAME, \"C3\")\n    print(cell_value)\n\n    # Set a one dimensional cell range.\n    # Cells are set using the format: [A1, A2, A3]\n    cell_values = [1, 2, 3]\n    sc.set_cells(SHEET_NAME, \"A1:A3\", cell_values)\n\n    # Retrieve one dimensional cell range.\n    cell_values = sc.get_cells(SHEET_NAME, \"C1:C3\")\n    print(cell_values)\n\n    # Set a two dimensional cell range.\n    # Cells are set using the format: [[A1, B1, C1], [A2, B2, C2], [A3, B3, C3]]\n    cell_values = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]\n    sc.set_cells(SHEET_NAME, \"A1:C3\", cell_values)\n\n    # Retrieve a two dimensional cell range.\n    cell_values = sc.get_cells(SHEET_NAME, \"A1:C3\")\n    print(cell_values)\n\n    # Save a spreadsheet - it will save into ./saved_spreadsheets\n    sc.save_spreadsheet(EXAMPLE_SPREADSHEET)\n\n    sc.disconnect()\n\n    os.remove(os.path.join(\"spreadsheets\", EXAMPLE_SPREADSHEET))\n"
  },
  {
    "path": "log/.gitignore",
    "content": "# Ignore everything in this directory\n*\n# Except this file\n!.gitignore\n\n"
  },
  {
    "path": "monitor.py",
    "content": "# Copyright (C) 2016 Robert Scott\n\n# This program is free software; you can redistribute it and/or\n# modify it under the terms of the GNU General Public License\n# as published by the Free Software Foundation; either version 2\n# of the License, or (at your option) any later version.\n\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n\n# You should have received a copy of the GNU General Public License\n# along with this program; if not, write to the Free Software\n# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.\n\nimport threading\nfrom os import listdir, remove\nfrom os.path import isfile, isdir, join, exists\nimport logging\nfrom time import sleep\nimport hashlib\nfrom glob import glob\n\n\nclass MonitorThread(threading.Thread):\n    \"\"\"Monitors the spreadsheet directory for changes.\"\"\"\n\n    def __init__(\n        self,\n        spreadsheets,\n        locks,\n        hashes,\n        soffice,\n        spreadsheets_path,\n        monitor_frequency,\n        reload_on_disk_change,\n    ):\n\n        self._stop_thread = threading.Event()\n\n        self.spreadsheets = spreadsheets\n        self.locks = locks\n        self.hashes = hashes\n        self.soffice = soffice\n        self.spreadsheets_path = spreadsheets_path\n        self.monitor_frequency = monitor_frequency\n        self.reload_on_disk_change = reload_on_disk_change\n\n        self.__delete_lock_files()\n\n        self.done_scan = False  # Done an initial scan or not\n\n        super(MonitorThread, self).__init__()\n\n    def stop_thread(self):\n        self._stop_thread.set()\n\n    def stopped(self):\n        return self._stop_thread.isSet()\n\n    def initial_scan(self):\n        return self.done_scan\n\n    def __get_full_path(self, doc):\n        return join(self.spreadsheets_path, doc)\n\n    def __delete_lock_files(self):\n        \"\"\"Lock files can cause issues opening documents.\"\"\"\n\n        lock_files = glob(\n            join(self.spreadsheets_path, \".~lock.*#\"), recursive=True\n        )\n        for lock_file in lock_files:\n            remove(lock_file)\n\n    def __load_spreadsheet(self, doc):\n        logging.info(\"Loading \" + doc[\"path\"])\n\n        self.spreadsheets[doc[\"path\"]] = self.soffice.open_spreadsheet(\n            self.__get_full_path(doc[\"path\"])\n        )\n        self.locks[doc[\"path\"]] = threading.Lock()\n        self.hashes[doc[\"path\"]] = doc[\"hash\"]\n\n    def __unload_spreadsheet(self, doc_path):\n        logging.info(\"Removing \" + doc_path)\n        self.locks[doc_path].acquire()\n        self.spreadsheets[doc_path].close()\n        self.spreadsheets.pop(doc_path, None)\n        self.locks.pop(doc_path, None)\n        self.hashes.pop(doc_path, None)\n\n    def __check_added(self):\n        \"\"\"Check for new spreadsheets and loads them into LibreOffice.\"\"\"\n\n        for doc in self.docs:\n            if doc[\"path\"][0] != \".\":  # Ignore hidden files\n                load = True  # Default to loading the spreadsheet\n\n                for key, value in self.spreadsheets.items():\n                    if doc[\"path\"] == key:\n\n                        # Check if the file has been modified\n                        # Does the file now have a differnet hash?\n\n                        if (\n                            self.reload_on_disk_change\n                            and doc[\"hash\"] != self.hashes[doc[\"path\"]]\n                        ):\n\n                            self.__unload_spreadsheet(doc[\"path\"])\n                        else:\n                            load = False\n\n                        break\n\n                if load:\n                    self.__load_spreadsheet(doc)\n\n    def __check_removed(self):\n        \"\"\"Check for any deleted or removed spreadsheets and remove them from\n        LibreOffice.\n        \"\"\"\n\n        removed_spreadsheets = []\n        for key, value in self.spreadsheets.items():\n            removed = True\n            for doc in self.docs:\n                if key == doc[\"path\"]:\n                    removed = False\n                    break\n            if removed:\n                removed_spreadsheets.append(key)\n\n        for doc_path in removed_spreadsheets:\n            self.__unload_spreadsheet(doc_path)\n\n    def __scan_directory(self, d):\n        \"\"\"Recursively scan a directory for spreadsheets.\"\"\"\n\n        dir_contents = listdir(d)\n\n        for f in dir_contents:\n\n            # Ignore particular files\n            if f[:7] == \".~lock.\" or f == \".gitignore\":\n                continue\n\n            full_path = join(d, f)\n            if isfile(full_path):\n                # Remove self.spreadsheets_path from the path\n                relative_path = full_path.split(self.spreadsheets_path)[1][1:]\n\n                # Calculate the MD5 hash for the file\n                hasher = hashlib.md5()\n                with open(self.__get_full_path(relative_path), \"rb\") as afile:\n                    buf = afile.read()\n                    hasher.update(buf)\n                    h = hasher.hexdigest()\n\n                self.docs.append({\"path\": relative_path, \"hash\": h})\n            elif isdir(full_path):\n                self.__scan_directory(full_path)\n\n    def run(self):\n        while not self.stopped():\n            self.docs = []\n\n            self.__scan_directory(self.spreadsheets_path)\n\n            self.__check_removed()\n            self.__check_added()\n\n            self.done_scan = True\n\n            sleep(self.monitor_frequency)\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.black]\nline-length = 79\ninclude = '\\.pyi?$'\nexclude = '''\n/(\n    \\.git\n  | \\.hg\n  | \\.mypy_cache\n  | \\.tox\n  | \\.venv\n  | _build\n  | buck-out\n  | build\n  | dist\n)/\n'''"
  },
  {
    "path": "request_handler.py",
    "content": "# Copyright (C) 2016 Robert Scott\n\n# This program is free software; you can redistribute it and/or\n# modify it under the terms of the GNU General Public License\n# as published by the Free Software Foundation; either version 2\n# of the License, or (at your option) any later version.\n\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n\n# You should have received a copy of the GNU General Public License\n# along with this program; if not, write to the Free Software\n# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.\n\nimport json\nimport logging\nimport select\nimport socketserver\nimport struct\nfrom socket import SHUT_RDWR\nfrom time import sleep\n\nfrom com.sun.star.uno import RuntimeException\nfrom com.sun.star.io import IOException\nfrom connection import SpreadsheetConnection\n\nTIMEOUT = 10\n\n\nclass ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):\n    def __init__(self, save_path, *args, **kwargs):\n        self.save_path = save_path\n        socketserver.TCPServer.__init__(self, *args, **kwargs)\n\n\nclass ThreadedTCPRequestHandler(socketserver.BaseRequestHandler):\n    def __send(self, msg):\n        \"\"\"Convert a message to JSON and send it to the client.\n\n        The messages are sent as utf-8 encoded bytes\n        \"\"\"\n\n        # What type is msg coming in as?\n        json_msg = json.dumps(msg)\n        json_msg = bytes(json_msg, \"utf-8\")\n\n        # Prepend the length of the string to the meg\n        json_msg = struct.pack(\">I\", len(json_msg)) + json_msg\n\n        self.request.send(json_msg)\n\n        logging.info(\"Sent: \" + json.dumps(msg))\n\n    def __receive(self):\n        \"\"\"Receive a message from the client, decode it from JSON and return.\n\n        The received messages are utf-8 encoded bytes. False is returned on\n        failure to connect to the client, otherwise a string of the message is\n        returned.\n        \"\"\"\n\n        raw_msg_length = self.__receive_length(4)\n        if not raw_msg_length:\n            return False\n        msg_length = struct.unpack(\">I\", raw_msg_length)[0]\n\n        recv = self.__receive_length(msg_length)\n\n        if recv == b\"\":\n            # The connection is closed.\n            return False\n\n        recv_json = str(recv, encoding=\"utf-8\")\n        recv_string = json.loads(recv_json)\n\n        logging.info(\"Received: \" + str(recv_string))\n        return recv_string\n\n    def __receive_length(self, length):\n        \"\"\"Receive length number of bytes from the client.\"\"\"\n\n        data = b\"\"\n        while len(data) < length:\n\n            ready = select.select([self.request], [], [], TIMEOUT)\n\n            if ready[0]:\n\n                packet = self.request.recv(length - len(data))\n                if not packet:\n                    return b\"\"\n                data += packet\n\n            else:\n                logging.warning(\"Waited too long to recieve from the client.\")\n                return b\"\"\n\n        return data\n\n    def __make_connection(self):\n        \"\"\"Handle first request to server and check that it adheres to the\n        protocol.\n        \"\"\"\n\n        def protocol_error():\n            # Invalid connection protocol\n            logging.error(\n                \"Client attempted to connect using and invalid protocol.\"\n            )\n            self.__send(\"PROTOCOL ERROR\")\n            self.__close_connection()\n            return False\n\n        data = self.__receive()\n\n        if type(data) != list:\n            return protocol_error()\n\n        if len(data) != 2:\n            return protocol_error()\n\n        if data[0] != \"SPREADSHEET\":\n            return protocol_error()\n\n        # If there is a KeyError when looking up the spreadsheets name, wait\n        # a bit and try again\n\n        max_attempts = self.server.monitor_frequency + 1\n        attempt = 0\n\n        while 1:\n            if attempt >= max_attempts:  # soffice process isin't coming up\n                logging.debug(\"Waited too long for spreadsheet\")\n                # We can assume the spreadsheet does not exist\n                self.__send(\"NOT FOUND\")\n                self.__close_connection()\n                return False\n\n            try:\n                self.con = SpreadsheetConnection(\n                    self.server.spreadsheets[data[1]],\n                    self.server.locks[data[1]],\n                    self.server.save_path,\n                )\n                break\n\n            except KeyError:\n                pass\n\n            attempt += 1\n            logging.debug(\"Waiting for spreadsheet\")\n            sleep(1)\n\n        # If the spreadsheet was sucessfully connected to\n        if attempt != max_attempts:\n            self.__send(\"OK\")\n            self.con.lock_spreadsheet()\n            return True\n\n    def __close_connection(self):\n        \"\"\"Unlock the spreadsheet and close the connection to the client.\"\"\"\n\n        try:\n            if self.con.lock.locked:\n                self.con.unlock_spreadsheet()\n        except (UnboundLocalError, AttributeError):\n            # con was never created.\n            pass\n\n        try:\n            self.request.shutdown(SHUT_RDWR)\n        except OSError:\n            # The client has already disconnected.\n            pass\n\n        logging.debug(\"Closing socket for ThreadedTCPRequestHandler\")\n        self.request.close()\n\n    def __main_loop(self):\n        while True:\n            data = self.__receive()\n\n            if data == False:\n                # The connection has been lost.\n                break\n\n            elif data[0] == \"SET\":\n                try:\n                    self.con.set_cells(data[1], data[2], data[3])\n                except (ValueError, RuntimeException) as e:\n                    self.__send({\"ERROR\": str(e)})\n                else:\n                    self.__send(\"OK\")\n\n            elif data[0] == \"GET\":\n                try:\n                    cells = self.con.get_cells(data[1], data[2])\n                except (ValueError, RuntimeException) as e:\n                    self.__send({\"ERROR\": str(e)})\n                else:\n                    self.__send(cells)\n\n            elif data[0] == \"GET_SHEETS\":\n                sheet_names = self.con.get_sheet_names()\n                self.__send(sheet_names)\n\n            elif data[0] == \"SAVE\":\n                try:\n                    self.con.save_spreadsheet(data[1])\n                except (IOException, OSError) as e:\n                    self.__send({\"ERROR\": str(e)})\n                else:\n                    self.__send(\"OK\")\n\n    def handle(self):\n        \"\"\"Make a connection to the client, run the main protocol loop and\n        close the connection.\n        \"\"\"\n\n        if self.__make_connection():\n            self.__main_loop()\n            self.__close_connection()\n"
  },
  {
    "path": "requirements.txt",
    "content": "pyoo==1.4\nWerkzeug==3.1.3"
  },
  {
    "path": "saved_spreadsheets/.gitignore",
    "content": "# Ignore everything in this directory\n*\n# Except this file\n!.gitignore\n\n"
  },
  {
    "path": "server.py",
    "content": "# Copyright (C) 2016 Robert Scott\n\n# This program is free software; you can redistribute it and/or\n# modify it under the terms of the GNU General Public License\n# as published by the Free Software Foundation; either version 2\n# of the License, or (at your option) any later version.\n\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n\n# You should have received a copy of the GNU General Public License\n# along with this program; if not, write to the Free Software\n# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.\n\nimport logging\nimport os\nimport socket\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nfrom time import sleep\n\nimport pyoo\nfrom monitor import MonitorThread\nfrom request_handler import ThreadedTCPRequestHandler, ThreadedTCPServer\n\n\nthis_dir = os.path.dirname(os.path.realpath(__file__))\nSAVE_PATH = os.path.join(this_dir, \"saved_spreadsheets\")\nSPREADSHEETS_PATH = os.path.join(this_dir, \"spreadsheets\")\nSOFFICE_LOG = os.path.join(this_dir, \"log\", \"soffice.log\")\nLOG_FILE = os.path.join(this_dir, \"log\", \"server.log\")\n\nSOFFICE_PROCNAME = \"soffice.bin\"\nHOST, PORT = \"localhost\", 5555\nSOFFICE_PIPE = \"soffice_headless\"\nMONITOR_FREQ = 5  # In seconds\nLOG_LEVEL = logging.DEBUG\n\n\nclass SpreadsheetServer:\n    def __init__(\n        self,\n        soffice_log=SOFFICE_LOG,\n        host=HOST,\n        port=PORT,\n        soffice_pipe=SOFFICE_PIPE,\n        spreadsheets_path=SPREADSHEETS_PATH,\n        monitor_frequency=MONITOR_FREQ,\n        reload_on_disk_change=True,\n        ask_kill=False,\n        save_path=SAVE_PATH,\n        log_level=LOG_LEVEL,\n        log_file=LOG_FILE,\n    ):\n        # Where the output from LibreOffice is logged to\n        self.soffice_log = soffice_log\n\n        self.host = host  # The address on which the server is listening\n        self.port = port  # The port on which the server is listening\n\n        # The name of the pipe set up by LibreOffice that pyoo will connect to.\n        self.soffice_pipe = soffice_pipe\n\n        # The frequency, in seconds, at which the directory containing the\n        # spreadsheets is polled.\n        self.monitor_frequency = monitor_frequency\n\n        # Whether or not to close and open a spreadsheet with the file changes on\n        # disk\n        self.reload_on_disk_change = reload_on_disk_change\n\n        # Whether or not to interactively ask the user if they want to kill an\n        # existing LibreOffice process.\n        self.ask_kill = ask_kill\n\n        # Where to save the spreadsheets when requested by the client.\n        self.save_path = save_path\n\n        # Where to look for spreadsheets to load.\n        self.spreadsheets_path = spreadsheets_path\n\n        self.spreadsheets = {}  # Each pyoo spreadsheet object.\n        self.locks = {}  # A lock for each spreadsheet.\n        self.hashes = {}  # A hash of the file contents for each spreadsheet.\n\n        self.log_level = log_level\n        self.log_file = log_file  # Where 'logging' logs to\n\n        self.libreoffice_temp_dir = tempfile.TemporaryDirectory()\n\n    def __logging(self):\n        \"\"\"Set up logging.\"\"\"\n\n        logging.basicConfig(\n            format=\"%(asctime)s:%(levelname)s:%(message)s\",\n            datefmt=\"%Y%m%d %H:%M:%S\",\n            # filename=self.log_file,\n            level=self.log_level,\n        )\n\n    def __start_soffice(self):\n        def get_soffice_binay_path():\n            try:\n                return str(\n                    subprocess.check_output([\"which\", \"soffice\"])[:-1],\n                    encoding=\"utf-8\",\n                )\n            except subprocess.CalledProcessError:\n                raise RuntimeError(\n                    \"The soffice binary was not found. Is LibreOffice installed?\"\n                )\n\n        soffice_path = get_soffice_binay_path()\n\n        command = (\n            soffice_path\n            + \" -env:UserInstallation=file://\"\n            + self.libreoffice_temp_dir.name\n            + ' --accept=\"pipe,name='\n            + self.soffice_pipe\n            + ';urp;\" --norestore --nologo --nodefault --headless --invisible --nocrashreport --nofirststartwizard'\n        )\n\n        self.logfile = open(self.soffice_log, \"w\")\n        self.soffice_process = subprocess.Popen(\n            command, shell=True, stdout=self.logfile, stderr=self.logfile\n        )\n\n    def __connect_to_soffice(self):\n        \"\"\"Make a connection to soffice and fail if it can not connect.\"\"\"\n\n        MAX_ATTEMPTS = 10\n\n        attempt = 0\n        while 1:\n            if attempt > MAX_ATTEMPTS:  # soffice process isin't coming up\n                raise RuntimeError(\n                    \"Could not connect to the soffice process. Did LibreOffice start?\"\n                )\n\n            try:\n                self.soffice = pyoo.Desktop(pipe=SOFFICE_PIPE)\n                logging.info(\"Connected to soffice.\")\n                break\n\n            except (OSError, IOError):\n                attempt += 1\n                sleep(1)  # Wait for the soffice process to start\n\n            except Exception:\n                exc_type, exc_obj, exc_tb = sys.exc_info()\n                fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]\n                print(exc_type, fname, exc_tb.tb_lineno)\n\n    def __start_threaded_tcp_server(self):\n        \"\"\"Set up and start the TCP threaded server to handle incomming\n        requests.\n        \"\"\"\n\n        logging.info(\"Starting spreadsheet_server.\")\n\n        MAX_ATTEMPTS = 10\n        attempt = 0\n\n        def start_threaded_tcp_server(attempt):\n            try:\n                self.server = ThreadedTCPServer(\n                    self.save_path,\n                    (self.host, self.port),\n                    ThreadedTCPRequestHandler,\n                )\n\n            except (OSError, socket.error):\n                attempt += 1\n\n                if attempt > MAX_ATTEMPTS:\n                    import traceback\n\n                    traceback.print_exc()\n                    print(\n                        \"Error: The port is in use. Maybe the server is already\"\n                        \"running?\"\n                    )\n                    exit()\n\n                sleep(1)\n                start_threaded_tcp_server(attempt)  # Try again\n\n        start_threaded_tcp_server(attempt)\n\n        self.server.spreadsheets = self.spreadsheets\n        self.server.locks = self.locks\n        self.server.hashes = self.hashes\n        self.server.monitor_frequency = self.monitor_frequency\n\n        # Start the main server thread. This server thread will start a\n        # new thread to handle each client connection.\n\n        self.server_thread = threading.Thread(target=self.server.serve_forever)\n\n        self.server_thread.daemon = False  # Gracefully stop child threads\n        self.server_thread.start()\n\n        logging.info(\"Server thread running. Waiting on connections...\")\n\n    def __start_monitor_thread(self):\n        \"\"\"This thread monitors the SPREADSHEETS directory to add or remove.\n        \"\"\"\n\n        self.monitor_thread = MonitorThread(\n            self.spreadsheets,\n            self.locks,\n            self.hashes,\n            self.soffice,\n            self.spreadsheets_path,\n            self.monitor_frequency,\n            self.reload_on_disk_change,\n        )\n\n        self.monitor_thread.daemon = True\n        self.monitor_thread.start()\n\n    def __stop_monitor_thread(self):\n        \"\"\"Stop the monitor thread.\"\"\"\n\n        self.monitor_thread.stop_thread()\n        self.monitor_thread.join()\n\n    def __stop_threaded_tcp_server(self):\n        \"\"\"Stop the ThreadedTCPServer.\"\"\"\n\n        try:\n            self.server.shutdown()\n            self.server.server_close()\n\n        except AttributeError:\n            # The server was never set up\n            pass\n\n    def __kill_libreoffice(self):\n        \"\"\"Terminate the soffice.bin process.\"\"\"\n\n        self.soffice_process.terminate()\n        self.soffice_process.wait()\n        self.libreoffice_temp_dir.cleanup()\n\n    def __close_logfile(self):\n        \"\"\"Close the logfile.\"\"\"\n\n        self.logfile.close()\n\n    def stop(self):\n        \"\"\"Stop all the threads and shutdown LibreOffice.\"\"\"\n\n        self.__stop_monitor_thread()\n        self.__stop_threaded_tcp_server()\n        self.__kill_libreoffice()\n        self.__close_logfile()\n\n    def run(self):\n        self.__logging()\n        self.__start_soffice()\n        self.__connect_to_soffice()\n        self.__start_monitor_thread()\n        self.__start_threaded_tcp_server()\n\n\nif __name__ == \"__main__\":\n    print(\"Starting spreadsheet_server...\")\n\n    spreadsheet_server = SpreadsheetServer(ask_kill=True)\n    try:\n        print(\"Logging to: \" + spreadsheet_server.log_file)\n        print(\"Connecting to LibreOffice...\")\n        spreadsheet_server.run()\n        print(\"Up and listening for connections!\")\n        while True:\n            sleep(100)\n\n    except KeyboardInterrupt:\n        print(\"Shutting down server. Please wait...\")\n        spreadsheet_server.stop()\n"
  },
  {
    "path": "spreadsheets/.gitignore",
    "content": "# Ignore everything in this directory\n*\n# Except this file\n!.gitignore\n\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/context.py",
    "content": "import os\nimport sys\n\nsys.path.insert(0, os.path.abspath(\"..\"))\n\nfrom connection import SpreadsheetConnection\nfrom server import SpreadsheetServer\nfrom monitor import MonitorThread\nfrom request_handler import ThreadedTCPServer, ThreadedTCPRequestHandler\nfrom client import SpreadsheetClient\n"
  },
  {
    "path": "tests/test_client.py",
    "content": "import unittest\nfrom .context import SpreadsheetServer, SpreadsheetClient\nfrom time import sleep\nimport os\nimport shutil\nimport sys\nimport logging\n\nEXAMPLE_SPREADSHEET = \"example.ods\"\nSOFFICE_PIPE = \"soffice_headless\"\nSPREADSHEETS_PATH = \"./spreadsheets\"\nTESTS_PATH = \"./tests\"\nSHEET_NAME = \"Sheet1\"\n\n\nclass TestClient(unittest.TestCase):\n    @classmethod\n    def setUpClass(cls):\n        # Copy the example spreadsheet from the tests directory into the spreadsheets\n        # directory\n\n        shutil.copyfile(\n            TESTS_PATH + \"/\" + EXAMPLE_SPREADSHEET,\n            SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET,\n        )\n\n        cls.server = SpreadsheetServer(log_level=logging.CRITICAL)\n        cls.server.run()\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.server.stop()\n        os.remove(SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET)\n\n    def setUp(self):\n        self.sc = SpreadsheetClient(EXAMPLE_SPREADSHEET)\n\n    def tearDown(self):\n        self.sc.disconnect()\n\n    def test_connect_invalid_spreadsheet(self):\n        try:\n            SpreadsheetClient(EXAMPLE_SPREADSHEET + \"z\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(\n                str(e), \"The requested spreadsheet was not found.\"\n            )\n\n            # Give the ThreadedTCPServer some time to shut down correctly before\n            # the next test\n            sleep(1)\n\n    def test_get_sheet_names(self):\n        sheet_names = self.sc.get_sheet_names()\n        self.assertEqual(sheet_names, [\"Sheet1\"])\n\n    def test_set_cell(self):\n        self.sc.set_cells(SHEET_NAME, \"A1\", 5)\n        a1 = self.sc.get_cells(SHEET_NAME, \"A1\")\n        self.assertEqual(a1, 5)\n\n    def test_set_cell_invalid_sheet(self):\n        try:\n            self.sc.set_cells(SHEET_NAME + \"z\", \"A1\", 5)\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Sheet name is invalid.\")\n\n    def test_get_cell(self):\n        cell_value = self.sc.get_cells(SHEET_NAME, \"C3\")\n        self.assertEqual(cell_value, 6)\n\n    def test_get_cell_invalid_sheet(self):\n        try:\n            self.sc.get_cells(SHEET_NAME + \"z\", \"C3\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Sheet name is invalid.\")\n\n    def test_get_invalid_cell_numeric(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, 1)\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_missing_alpha(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"1\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_missing_numeric(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"A\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_missing_start_numeric(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"A:B2\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_missing_end_numeric(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"A1:B\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_missing_start_alpha(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"1:B2\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_missing_end_alpha(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"A1:2\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_negative(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"A-1:B2\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_numeric_too_large(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"A1048577\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_get_invalid_cell_alpha_too_large(self):\n        try:\n            self.sc.get_cells(SHEET_NAME, \"AMK1\")\n            self.assertTrue(False)\n        except RuntimeError as e:\n            self.assertEqual(str(e), \"Cell range is invalid.\")\n\n    def test_set_cell_row(self):\n        cell_values = [4, 5, 6]\n        self.sc.set_cells(SHEET_NAME, \"A1:A3\", cell_values)\n\n        saved_values = self.sc.get_cells(SHEET_NAME, \"A1:A3\")\n        self.assertEqual(cell_values, saved_values)\n\n    def test_get_cell_column(self):\n        cell_values = self.sc.get_cells(SHEET_NAME, \"C1:C3\")\n        self.assertEqual(cell_values, [3, 3.5, 6])\n\n    def test_get_cell_column_large_alpha(self):\n        cell_values = self.sc.get_cells(SHEET_NAME, \"AF5:AF186\")\n        self.assertEqual(cell_values, [\"\" for x in range(5, 186 + 1)])\n\n    def test_set_cell_range(self):\n        cell_values = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]\n        self.sc.set_cells(SHEET_NAME, \"A1:C3\", cell_values)\n\n        saved_values = self.sc.get_cells(SHEET_NAME, \"A1:C3\")\n        self.assertEqual(cell_values, saved_values)\n\n    def test_save_spreadsheet(self):\n        filename = \"test.ods\"\n        self.sc.save_spreadsheet(filename)\n\n        dir_path = os.path.dirname(os.path.realpath(__file__))\n\n        saved_path = dir_path + \"/../saved_spreadsheets/\" + filename\n        self.assertTrue(os.path.exists(saved_path))\n\n        os.remove(saved_path)\n\n    def test_unicode(self):\n        for i in range(1000):\n            self.sc.set_cells(SHEET_NAME, \"A1\", chr(i))\n            cell = self.sc.get_cells(SHEET_NAME, \"A1\")\n            try:\n                int(chr(i))\n                continue\n            except ValueError:\n                pass  # Not a number\n\n            self.assertEqual(chr(i), cell)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_connection.py",
    "content": "import os\nimport shutil\nimport threading\nimport unittest\nfrom signal import SIGTERM\n\nfrom .context import SpreadsheetConnection, SpreadsheetServer\n\nEXAMPLE_SPREADSHEET = \"example.ods\"\nSOFFICE_PIPE = \"soffice_headless\"\nSPREADSHEETS_PATH = \"./spreadsheets\"\nTESTS_PATH = \"./tests\"\n\n\nclass TestConnection(unittest.TestCase):\n    @classmethod\n    def setUpClass(cls):\n        # Copy the example spreadsheet from the tests directory into the spreadsheets\n        # directory\n\n        shutil.copyfile(\n            TESTS_PATH + \"/\" + EXAMPLE_SPREADSHEET,\n            SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET,\n        )\n\n        cls.spreadsheet_server = SpreadsheetServer()\n        cls.spreadsheet_server._SpreadsheetServer__start_soffice()\n        cls.spreadsheet_server._SpreadsheetServer__connect_to_soffice()\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.spreadsheet_server._SpreadsheetServer__kill_libreoffice()\n        cls.spreadsheet_server._SpreadsheetServer__close_logfile()\n        os.remove(SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET)\n\n    def setUp(self):\n        soffice = self.spreadsheet_server.soffice\n        self.spreadsheet = soffice.open_spreadsheet(\n            SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET\n        )\n\n        lock = threading.Lock()\n        self.ss_con = SpreadsheetConnection(\n            self.spreadsheet, lock, self.spreadsheet_server.save_path\n        )\n\n    def tearDown(self):\n        self.spreadsheet.close()\n\n    def test_lock_spreadsheet(self):\n        self.ss_con.lock_spreadsheet()\n        self.assertTrue(self.ss_con.lock.locked())\n        self.ss_con.unlock_spreadsheet()\n\n    def test_unlock_spreadsheet(self):\n        self.ss_con.lock_spreadsheet()\n        status = self.ss_con.unlock_spreadsheet()\n        self.assertTrue(status)\n        self.assertFalse(self.ss_con.lock.locked())\n\n    def test_unlock_spreadsheet_runtime_error(self):\n        status = self.ss_con.unlock_spreadsheet()\n        self.assertFalse(status)\n        self.assertFalse(self.ss_con.lock.locked())\n\n    def test_get_xy_index_first_cell(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"A1\")\n        self.assertEqual(alpha_index, 0)\n        self.assertEqual(num_index, 0)\n\n    def test_get_xy_index_Z26(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"Z26\")\n        self.assertEqual(alpha_index, 25)\n        self.assertEqual(num_index, 25)\n\n    def test_get_xy_index_aa3492(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"AA3492\")\n        self.assertEqual(alpha_index, 26)\n        self.assertEqual(num_index, 3491)\n\n    def test_get_xy_index_aaa1024(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"AAA1024\")\n        self.assertEqual(alpha_index, 702)\n        self.assertEqual(num_index, 1023)\n\n    def test_get_xy_index_aab739(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"AAB739\")\n        self.assertEqual(alpha_index, 703)\n        self.assertEqual(num_index, 738)\n\n    def test_get_xy_index_aba1(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"ABA1\")\n        self.assertEqual(alpha_index, 728)\n        self.assertEqual(num_index, 0)\n\n    def test_get_xy_index_abc123(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"ABC123\")\n        self.assertEqual(alpha_index, 730)\n        self.assertEqual(num_index, 122)\n\n    def test_get_xy_index_last_cell(self):\n        (\n            alpha_index,\n            num_index,\n        ) = self.ss_con._SpreadsheetConnection__get_xy_index(u\"AMJ1048576\")\n        self.assertEqual(alpha_index, 1023)\n        self.assertEqual(num_index, 1048575)\n\n    def test_is_single_cell(self):\n        status = self.ss_con._SpreadsheetConnection__is_single_cell(u\"AMJ1\")\n        self.assertTrue(status)\n\n    def test_is_not_single_cell(self):\n        status = self.ss_con._SpreadsheetConnection__is_single_cell(u\"A1:Z26\")\n        self.assertFalse(status)\n\n    def test_check_not_single_cell(self):\n        status = True\n        try:\n            self.ss_con._SpreadsheetConnection__check_single_cell(u\"DD56:Z98\")\n        except ValueError:\n            status = False\n\n        self.assertFalse(status)\n\n    def test_cell_to_index(self):\n        d = self.ss_con._SpreadsheetConnection__cell_to_index(u\"ABC945\")\n\n        self.assertTrue(d[\"row_index\"] == 944)\n        self.assertTrue(d[\"column_index\"] == 730)\n\n    def test_cell_range_to_index(self):\n        d = self.ss_con._SpreadsheetConnection__cell_range_to_index(u\"C9:Z26\")\n\n        self.assertTrue(d[\"row_start\"] == 8)\n        self.assertTrue(d[\"row_end\"] == 25)\n\n        self.assertTrue(d[\"column_start\"] == 2)\n        self.assertTrue(d[\"column_end\"] == 25)\n\n    def test_check_for_lock(self):\n        status = False\n        try:\n            self.ss_con._SpreadsheetConnection__check_for_lock()\n        except RuntimeError:\n            status = True\n\n        self.assertTrue(status)\n\n    def test_check_numeric(self):\n        value = self.ss_con._SpreadsheetConnection__convert_to_float_if_numeric(\n            \"1\"\n        )\n        self.assertTrue(type(value) is float)\n\n    def test_check_not_numeric(self):\n        value = self.ss_con._SpreadsheetConnection__convert_to_float_if_numeric(\n            u\"123A\"\n        )\n\n        self.assertTrue(type(value) is str)\n\n    def test_check_not_list(self):\n        status = False\n        try:\n            self.ss_con._SpreadsheetConnection__check_list(1)\n        except ValueError:\n            status = True\n\n        self.assertTrue(status)\n\n    def test_check_1D_list(self):\n        data = [\"1\", \"2\", \"3\"]\n        data = self.ss_con._SpreadsheetConnection__check_1D_list(data)\n\n        self.assertEqual(data, [1.0, 2.0, 3.0])\n\n    def test_check_1D_list_when_2D(self):\n        status = False\n        data = [[\"1\", \"2\", \"3\"], [\"1\", \"2\", \"3\"]]\n        try:\n            data = self.ss_con._SpreadsheetConnection__check_1D_list(data)\n        except ValueError:\n            status = True\n\n        self.assertTrue(status)\n\n    def test_invalid_sheet_name(self):\n        self.ss_con.lock_spreadsheet()\n        status = False\n        try:\n            self.ss_con.set_cells(u\"1Sheet1\", u\"A1\", 1)\n        except ValueError:\n            status = True\n        self.ss_con.unlock_spreadsheet()\n\n        self.assertTrue(status)\n\n    def test_set_single_cell(self):\n        self.ss_con.lock_spreadsheet()\n        self.ss_con.set_cells(u\"Sheet1\", u\"A1\", 1)\n        self.assertEqual(self.ss_con.get_cells(u\"Sheet1\", u\"A1\"), 1)\n        self.ss_con.unlock_spreadsheet()\n\n    def test_set_single_cell_list_of_data(self):\n        self.ss_con.lock_spreadsheet()\n        status = False\n        try:\n            self.ss_con.set_cells(u\"Sheet1\", u\"A1\", [9, 1])\n        except ValueError:\n            status = True\n\n        self.assertTrue(status)\n        self.assertNotEqual(self.ss_con.get_cells(u\"Sheet1\", u\"A1\"), 9)\n        self.ss_con.unlock_spreadsheet()\n\n    def test_set_cell_range_columnn(self):\n        self.ss_con.lock_spreadsheet()\n        self.ss_con.set_cells(u\"Sheet1\", u\"A1:A5\", [1, 2, 3, 4, 5])\n        self.assertEqual(\n            self.ss_con.get_cells(u\"Sheet1\", u\"A1:A5\"),\n            (1.0, 2.0, 3.0, 4.0, 5.0),\n        )\n        self.ss_con.unlock_spreadsheet()\n\n    def test_set_cell_range_row(self):\n        self.ss_con.lock_spreadsheet()\n        self.ss_con.set_cells(u\"Sheet1\", u\"A1:E1\", [1, 2, 3, 4, 5])\n        self.assertEqual(\n            self.ss_con.get_cells(u\"Sheet1\", u\"A1:E1\"),\n            (1.0, 2.0, 3.0, 4.0, 5.0),\n        )\n        self.ss_con.unlock_spreadsheet()\n\n    def test_set_cell_range_2D(self):\n        self.ss_con.lock_spreadsheet()\n        self.ss_con.set_cells(u\"Sheet1\", u\"A1:B2\", [[1, 2], [3, 4]])\n        self.assertEqual(\n            self.ss_con.get_cells(u\"Sheet1\", u\"A1:B2\"),\n            ((1.0, 2.0), (3.0, 4.0)),\n        )\n        self.ss_con.unlock_spreadsheet()\n\n    def test_set_cell_range_2D_incorrect_data(self):\n        self.ss_con.lock_spreadsheet()\n        status = False\n        try:\n            self.ss_con.set_cells(u\"Sheet1\", u\"A1:B2\", [9, 9, 9, 9])\n        except ValueError:\n            status = True\n\n        self.assertTrue(status)\n\n        self.assertNotEqual(\n            self.ss_con.get_cells(u\"Sheet1\", u\"A1:B2\"),\n            ((9.0, 9.0), (9.0, 9.0)),\n        )\n        self.ss_con.unlock_spreadsheet()\n\n    def test_get_sheet_names(self):\n        sheet_names = self.ss_con.get_sheet_names()\n        self.assertEqual(sheet_names, [u\"Sheet1\"])\n\n    def test_save_spreadsheet(self):\n        path = \"./saved_spreadsheets/\" + EXAMPLE_SPREADSHEET + \".new\"\n\n        if os.path.exists(path):\n            os.remove(path)\n\n        self.ss_con.lock_spreadsheet()\n        status = self.ss_con.save_spreadsheet(EXAMPLE_SPREADSHEET + \".new\")\n        self.assertTrue(status)\n        self.assertTrue(os.path.exists(path))\n        self.ss_con.unlock_spreadsheet()\n\n    def test_save_spreadsheet_no_lock(self):\n        path = \"./saved_spreadsheets/\" + EXAMPLE_SPREADSHEET + \".new\"\n\n        if os.path.exists(path):\n            os.remove(path)\n\n        status = self.ss_con.save_spreadsheet(EXAMPLE_SPREADSHEET + \".new\")\n        self.assertFalse(status)\n        self.assertFalse(\n            os.path.exists(\n                \"./saved_spreadsheets/\" + EXAMPLE_SPREADSHEET + \".new\"\n            )\n        )\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_monitor.py",
    "content": "import os\nimport shutil\nimport unittest\nfrom time import sleep\n\nfrom .context import MonitorThread, SpreadsheetClient, SpreadsheetServer\n\nthis_dir = os.path.dirname(os.path.realpath(__file__))\nparent_dir = os.path.dirname(this_dir)\n\nSPREADSHEETS_PATH = os.path.join(parent_dir, \"spreadsheets\")\nTESTS_PATH = this_dir\nSAVED_SPREADSHEETS_PATH = os.path.join(parent_dir, \"saved_spreadsheets\")\n\nEXAMPLE_SPREADSHEET = \"example.ods\"\nEXAMPLE_SPREADSHEET_MOVED = \"example_moved.ods\"\nSOFFICE_PIPE = \"soffice_headless\"\nSHEET_NAME = \"Sheet1\"\n\n\nclass TestMonitor(unittest.TestCase):\n    @classmethod\n    def setUpClass(cls):\n        # Copy the example spreadsheet from the tests directory into the spreadsheets\n        # directory\n\n        shutil.copyfile(\n            TESTS_PATH + \"/\" + EXAMPLE_SPREADSHEET,\n            SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET,\n        )\n\n        cls.spreadsheet_server = SpreadsheetServer()\n        cls.spreadsheet_server._SpreadsheetServer__start_soffice()\n        cls.spreadsheet_server._SpreadsheetServer__connect_to_soffice()\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.spreadsheet_server._SpreadsheetServer__kill_libreoffice()\n        cls.spreadsheet_server._SpreadsheetServer__close_logfile()\n\n        os.remove(SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET)\n\n    def setUp(self):\n        self.spreadsheet_server._SpreadsheetServer__start_monitor_thread()\n        self.monitor_thread = self.spreadsheet_server.monitor_thread\n        while not self.monitor_thread.initial_scan():\n            sleep(0.5)\n\n    def tearDown(self):\n        self.spreadsheet_server._SpreadsheetServer__stop_monitor_thread()\n\n    def test_unload_spreadsheet(self):\n        self.monitor_thread._MonitorThread__unload_spreadsheet(\n            EXAMPLE_SPREADSHEET\n        )\n\n        spreadsheets = [\n            key for key, value in self.monitor_thread.spreadsheets.items()\n        ]\n\n        locks = [key for key, value in self.monitor_thread.locks.items()]\n\n        self.assertTrue(EXAMPLE_SPREADSHEET not in spreadsheets)\n        self.assertTrue(EXAMPLE_SPREADSHEET not in locks)\n\n    def test_check_added_already_exists(self):\n        self.monitor_thread._MonitorThread__check_added()\n\n        spreadsheets = [\n            key for key, value in self.monitor_thread.spreadsheets.items()\n        ]\n\n        locks = [key for key, value in self.monitor_thread.locks.items()]\n\n        self.assertTrue(EXAMPLE_SPREADSHEET in spreadsheets)\n        self.assertTrue(EXAMPLE_SPREADSHEET in locks)\n\n    def test_check_removed_when_renamed(self):\n        # Rename example.ods to example_moved.ods\n\n        current_loc = SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET\n        moved_loc = SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET_MOVED\n\n        os.rename(current_loc, moved_loc)\n\n        self.monitor_thread.docs = []\n        self.monitor_thread._MonitorThread__scan_directory(SPREADSHEETS_PATH)\n        self.monitor_thread._MonitorThread__check_removed()\n        self.monitor_thread._MonitorThread__check_added()\n\n        spreadsheets = [\n            key for key, value in self.monitor_thread.spreadsheets.items()\n        ]\n\n        locks = [key for key, value in self.monitor_thread.locks.items()]\n\n        self.assertTrue(EXAMPLE_SPREADSHEET not in spreadsheets)\n        self.assertTrue(EXAMPLE_SPREADSHEET not in locks)\n        self.assertTrue(EXAMPLE_SPREADSHEET_MOVED in spreadsheets)\n        self.assertTrue(EXAMPLE_SPREADSHEET_MOVED in locks)\n\n        # Move it back to where it was\n        os.rename(moved_loc, current_loc)\n\n    def test_change_file_hash(self):\n        # Save the example file with a modification\n\n        current_loc = SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET\n        moved_loc = SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET_MOVED\n        shutil.copyfile(current_loc, moved_loc)\n\n        self.spreadsheet_server._SpreadsheetServer__start_threaded_tcp_server()\n\n        self.sc = SpreadsheetClient(EXAMPLE_SPREADSHEET_MOVED)\n        self.sc.set_cells(SHEET_NAME, \"A1\", 5)\n        self.sc.save_spreadsheet(EXAMPLE_SPREADSHEET_MOVED)\n        self.sc.disconnect()\n\n        current_loc = SAVED_SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET_MOVED\n        moved_loc = SPREADSHEETS_PATH + \"/\" + EXAMPLE_SPREADSHEET_MOVED\n\n        hash_before = self.monitor_thread.hashes[EXAMPLE_SPREADSHEET_MOVED]\n\n        os.rename(current_loc, moved_loc)\n\n        # Run a scan manually\n        self.monitor_thread.docs = []\n        self.monitor_thread._MonitorThread__scan_directory(SPREADSHEETS_PATH)\n        self.monitor_thread._MonitorThread__check_removed()\n        self.monitor_thread._MonitorThread__check_added()\n\n        hash_after = self.monitor_thread.hashes[EXAMPLE_SPREADSHEET_MOVED]\n        self.assertNotEqual(hash_before, hash_after)\n\n        self.sc = SpreadsheetClient(EXAMPLE_SPREADSHEET_MOVED)\n        cell = self.sc.get_cells(SHEET_NAME, \"A1\")\n        self.sc.save_spreadsheet(EXAMPLE_SPREADSHEET_MOVED)\n        self.sc.disconnect()\n\n        self.assertEqual(cell, 5)\n\n        self.spreadsheet_server._SpreadsheetServer__stop_threaded_tcp_server()\n        os.remove(moved_loc)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  }
]