diff --git a/.gitignore b/.gitignore index 5871f26..1918894 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ *.pyc -build +MANIFEST +build/ +dist/ +Minecraft_Overviewer.egg-info terrain.png cachedir* @@ -14,10 +17,13 @@ ImPlatform.h Imaging.h # various forms of compiled c_overviewer extensions -c_overviewer.so -c_overviewer.pyd -c_overviewer_d.pyd -c_overviewer.dylib +overviewer_core/c_overviewer.so +overviewer_core/c_overviewer.pyd +overviewer_core/c_overviewer_d.pyd +overviewer_core/c_overviewer.dylib + +# generated version file +overviewer_core/overviewer_version.py # Mac OS X noise .DS_Store diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fe84bcb --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include COPYING.txt +include README.rst +include CONTRIBUTORS.rst +include overviewer.py +include sample.settings.py +recursive-include contrib/ *.py +recursive-include overviewer_core/*.py +recursive-include overviewer_core/src/ *.c *.h +recursive-include overviewer_core/data/ *.png *.js index.html style.css diff --git a/blockcounter.py b/contrib/blockcounter.py similarity index 100% rename from blockcounter.py rename to contrib/blockcounter.py diff --git a/overviewer.py b/overviewer.py index 9f13b8f..29502aa 100755 --- a/overviewer.py +++ b/overviewer.py @@ -22,14 +22,13 @@ if not (sys.version_info[0] == 2 and sys.version_info[1] >= 6): import os import os.path -from configParser import ConfigOptionParser import re import subprocess import multiprocessing import time import logging -import util import platform +from overviewer_core import util logging.basicConfig(level=logging.INFO,format="%(asctime)s [%(levelname)s] %(message)s") @@ -37,7 +36,7 @@ this_dir = util.get_program_path() # make sure the c_overviewer extension is available try: - import c_overviewer + from overviewer_core import c_overviewer except ImportError: ## if this is a frozen windows package, the following error messages about ## building the c_overviewer extension are not appropriate @@ -49,7 +48,7 @@ except ImportError: ## try to find the build extension - ext = os.path.join(this_dir, "c_overviewer.%s" % ("pyd" if platform.system() == "Windows" else "so")) + ext = os.path.join(this_dir, "overviewer_core", "c_overviewer.%s" % ("pyd" if platform.system() == "Windows" else "so")) if os.path.exists(ext): print "Something has gone wrong importing the c_overviewer extension. Please" print "make sure it is up-to-date (clean and rebuild)" @@ -57,14 +56,17 @@ except ImportError: print "You need to compile the c_overviewer module to run Minecraft Overviewer." print "Run `python setup.py build`, or see the README for details." + import traceback + traceback.print_exc() sys.exit(1) + if hasattr(sys, "frozen"): pass # we don't bother with a compat test since it should always be in sync elif "extension_version" in dir(c_overviewer): # check to make sure the binary matches the headers - if os.path.exists(os.path.join(this_dir, "src", "overviewer.h")): - with open(os.path.join(this_dir, "src", "overviewer.h")) as f: + if os.path.exists(os.path.join(this_dir, "overviewer_core", "src", "overviewer.h")): + with open(os.path.join(this_dir, "overviewer_core", "src", "overviewer.h")) as f: lines = f.readlines() lines = filter(lambda x: x.startswith("#define OVERVIEWER_EXTENSION_VERSION"), lines) if lines: @@ -76,12 +78,10 @@ else: print "Please rebuild your c_overviewer module. It is out of date!" sys.exit(1) +from overviewer_core.configParser import ConfigOptionParser +from overviewer_core import optimizeimages, world, quadtree +from overviewer_core import googlemap, rendernode -import optimizeimages -import world -import quadtree -import googlemap -import rendernode helptext = """ %prog [OPTIONS] @@ -124,14 +124,14 @@ def main(): if options.version: - print "Minecraft-Overviewer" - print "Git version: %s" % util.findGitVersion() try: - import overviewer_version - if hasattr(sys, "frozen"): - print "py2exe version build on %s" % overviewer_version.BUILD_DATE - print "Build machine: %s %s" % (overviewer_version.BUILD_PLATFORM, overviewer_version.BUILD_OS) + import overviewer_core.overviewer_version as overviewer_version + print "Minecraft-Overviewer %s" % overviewer_version.VERSION + print "Git commit: %s" % overviewer_version.HASH + print "built on %s" % overviewer_version.BUILD_DATE + print "Build machine: %s %s" % (overviewer_version.BUILD_PLATFORM, overviewer_version.BUILD_OS) except: + print "version info not found" pass sys.exit(0) diff --git a/overviewer_core/__init__.py b/overviewer_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chunk.py b/overviewer_core/chunk.py similarity index 100% rename from chunk.py rename to overviewer_core/chunk.py diff --git a/composite.py b/overviewer_core/composite.py similarity index 100% rename from composite.py rename to overviewer_core/composite.py diff --git a/configParser.py b/overviewer_core/configParser.py similarity index 100% rename from configParser.py rename to overviewer_core/configParser.py diff --git a/textures/fire.png b/overviewer_core/data/textures/fire.png similarity index 100% rename from textures/fire.png rename to overviewer_core/data/textures/fire.png diff --git a/textures/lava.png b/overviewer_core/data/textures/lava.png similarity index 100% rename from textures/lava.png rename to overviewer_core/data/textures/lava.png diff --git a/textures/portal.png b/overviewer_core/data/textures/portal.png similarity index 100% rename from textures/portal.png rename to overviewer_core/data/textures/portal.png diff --git a/textures/water.png b/overviewer_core/data/textures/water.png similarity index 100% rename from textures/water.png rename to overviewer_core/data/textures/water.png diff --git a/web_assets/compass.png b/overviewer_core/data/web_assets/compass.png similarity index 100% rename from web_assets/compass.png rename to overviewer_core/data/web_assets/compass.png diff --git a/web_assets/control-bg-active.png b/overviewer_core/data/web_assets/control-bg-active.png similarity index 100% rename from web_assets/control-bg-active.png rename to overviewer_core/data/web_assets/control-bg-active.png diff --git a/web_assets/control-bg.png b/overviewer_core/data/web_assets/control-bg.png similarity index 100% rename from web_assets/control-bg.png rename to overviewer_core/data/web_assets/control-bg.png diff --git a/web_assets/index.html b/overviewer_core/data/web_assets/index.html similarity index 100% rename from web_assets/index.html rename to overviewer_core/data/web_assets/index.html diff --git a/web_assets/overviewer.css b/overviewer_core/data/web_assets/overviewer.css similarity index 100% rename from web_assets/overviewer.css rename to overviewer_core/data/web_assets/overviewer.css diff --git a/web_assets/overviewer.js b/overviewer_core/data/web_assets/overviewer.js similarity index 100% rename from web_assets/overviewer.js rename to overviewer_core/data/web_assets/overviewer.js diff --git a/web_assets/overviewerConfig.js b/overviewer_core/data/web_assets/overviewerConfig.js similarity index 100% rename from web_assets/overviewerConfig.js rename to overviewer_core/data/web_assets/overviewerConfig.js diff --git a/web_assets/signpost-shadow.png b/overviewer_core/data/web_assets/signpost-shadow.png similarity index 100% rename from web_assets/signpost-shadow.png rename to overviewer_core/data/web_assets/signpost-shadow.png diff --git a/web_assets/signpost.png b/overviewer_core/data/web_assets/signpost.png similarity index 100% rename from web_assets/signpost.png rename to overviewer_core/data/web_assets/signpost.png diff --git a/web_assets/signpost_icon.png b/overviewer_core/data/web_assets/signpost_icon.png similarity index 100% rename from web_assets/signpost_icon.png rename to overviewer_core/data/web_assets/signpost_icon.png diff --git a/googlemap.py b/overviewer_core/googlemap.py similarity index 93% rename from googlemap.py rename to overviewer_core/googlemap.py index 48fede1..7d4dc7e 100644 --- a/googlemap.py +++ b/overviewer_core/googlemap.py @@ -24,6 +24,7 @@ import json import util from c_overviewer import get_render_mode_inheritance +import overviewer_version """ This module has routines related to generating a Google Maps-based @@ -98,7 +99,11 @@ class MapGen(object): blank.save(os.path.join(tileDir, "blank."+quadtree.imgformat)) # copy web assets into destdir: - mirror_dir(os.path.join(util.get_program_path(), "web_assets"), self.destdir) + global_assets = os.path.join(util.get_program_path(), "overviewer_core", "data", "web_assets") + if not os.path.isdir(global_assets): + global_assets = os.path.join(util.get_program_path(), "web_assets") + mirror_dir(global_assets, self.destdir) + # do the same with the local copy, if we have it if self.web_assets_path: mirror_dir(self.web_assets_path, self.destdir) @@ -131,9 +136,9 @@ class MapGen(object): indexpath = os.path.join(self.destdir, "index.html") index = open(indexpath, 'r').read() - index = index.replace( - "{time}", str(strftime("%a, %d %b %Y %H:%M:%S %Z", localtime()))) - index = index.replace("{version}", util.findGitVersion()) + index = index.replace("{time}", str(strftime("%a, %d %b %Y %H:%M:%S %Z", localtime()))) + versionstr = "%s (%s)" % (overviewer_version.VERSION, overviewer_version.HASH[:7]) + index = index.replace("{version}", versionstr) with open(os.path.join(self.destdir, "index.html"), 'w') as output: output.write(index) diff --git a/nbt.py b/overviewer_core/nbt.py similarity index 100% rename from nbt.py rename to overviewer_core/nbt.py diff --git a/optimizeimages.py b/overviewer_core/optimizeimages.py similarity index 100% rename from optimizeimages.py rename to overviewer_core/optimizeimages.py diff --git a/quadtree.py b/overviewer_core/quadtree.py similarity index 100% rename from quadtree.py rename to overviewer_core/quadtree.py diff --git a/rendernode.py b/overviewer_core/rendernode.py similarity index 100% rename from rendernode.py rename to overviewer_core/rendernode.py diff --git a/src/Draw.c b/overviewer_core/src/Draw.c similarity index 100% rename from src/Draw.c rename to overviewer_core/src/Draw.c diff --git a/src/composite.c b/overviewer_core/src/composite.c similarity index 100% rename from src/composite.c rename to overviewer_core/src/composite.c diff --git a/src/endian.c b/overviewer_core/src/endian.c similarity index 100% rename from src/endian.c rename to overviewer_core/src/endian.c diff --git a/src/iterate.c b/overviewer_core/src/iterate.c similarity index 99% rename from src/iterate.c rename to overviewer_core/src/iterate.c index d86ecbf..29d1219 100644 --- a/src/iterate.c +++ b/overviewer_core/src/iterate.c @@ -33,13 +33,13 @@ PyObject *init_chunk_render(PyObject *self, PyObject *args) { return NULL; } - textures = PyImport_ImportModule("textures"); + textures = PyImport_ImportModule("overviewer_core.textures"); /* ensure none of these pointers are NULL */ if ((!textures)) { return NULL; } - chunk_mod = PyImport_ImportModule("chunk"); + chunk_mod = PyImport_ImportModule("overviewer_core.chunk"); /* ensure none of these pointers are NULL */ if ((!chunk_mod)) { return NULL; diff --git a/src/main.c b/overviewer_core/src/main.c similarity index 100% rename from src/main.c rename to overviewer_core/src/main.c diff --git a/src/overviewer.h b/overviewer_core/src/overviewer.h similarity index 100% rename from src/overviewer.h rename to overviewer_core/src/overviewer.h diff --git a/src/rendermode-cave.c b/overviewer_core/src/rendermode-cave.c similarity index 100% rename from src/rendermode-cave.c rename to overviewer_core/src/rendermode-cave.c diff --git a/src/rendermode-lighting.c b/overviewer_core/src/rendermode-lighting.c similarity index 100% rename from src/rendermode-lighting.c rename to overviewer_core/src/rendermode-lighting.c diff --git a/src/rendermode-night.c b/overviewer_core/src/rendermode-night.c similarity index 100% rename from src/rendermode-night.c rename to overviewer_core/src/rendermode-night.c diff --git a/src/rendermode-normal.c b/overviewer_core/src/rendermode-normal.c similarity index 100% rename from src/rendermode-normal.c rename to overviewer_core/src/rendermode-normal.c diff --git a/src/rendermode-overlay.c b/overviewer_core/src/rendermode-overlay.c similarity index 100% rename from src/rendermode-overlay.c rename to overviewer_core/src/rendermode-overlay.c diff --git a/src/rendermode-spawn.c b/overviewer_core/src/rendermode-spawn.c similarity index 100% rename from src/rendermode-spawn.c rename to overviewer_core/src/rendermode-spawn.c diff --git a/src/rendermodes.c b/overviewer_core/src/rendermodes.c similarity index 100% rename from src/rendermodes.c rename to overviewer_core/src/rendermodes.c diff --git a/src/rendermodes.h b/overviewer_core/src/rendermodes.h similarity index 100% rename from src/rendermodes.h rename to overviewer_core/src/rendermodes.h diff --git a/textures.py b/overviewer_core/textures.py similarity index 99% rename from textures.py rename to overviewer_core/textures.py index 5e2db3b..f1da1b2 100644 --- a/textures.py +++ b/overviewer_core/textures.py @@ -14,6 +14,7 @@ # with the Overviewer. If not, see . import sys +import imp import os import os.path import zipfile @@ -32,8 +33,8 @@ def _find_file(filename, mode="rb"): This searches the following locations in this order: * the textures_path given in the config file (if present) - * The program dir (same dir as this file) - * The program dir / textures + * The program dir (same dir as overviewer.py) + * The overviewer_core textures dir * On Darwin, in /Applications/Minecraft * Inside minecraft.jar, which is looked for at these locations @@ -53,9 +54,14 @@ def _find_file(filename, mode="rb"): if os.path.exists(path): return open(path, mode) - path = os.path.join(programdir, "textures", filename) + path = os.path.join(programdir, "overviewer_core", "data", "textures", filename) if os.path.exists(path): return open(path, mode) + elif hasattr(sys, "frozen") or imp.is_frozen("__main__"): + # windows special case, when the package dir doesn't exist + path = os.path.join(programdir, "textures", filename) + if os.path.exists(path): + return open(path, mode) if sys.platform == "darwin": path = os.path.join("/Applications/Minecraft", filename) diff --git a/util.py b/overviewer_core/util.py similarity index 68% rename from util.py rename to overviewer_core/util.py index 7a0323d..2bd15b5 100644 --- a/util.py +++ b/overviewer_core/util.py @@ -21,19 +21,22 @@ import imp import os import os.path import sys +from subprocess import Popen, PIPE def get_program_path(): if hasattr(sys, "frozen") or imp.is_frozen("__main__"): return os.path.dirname(sys.executable) else: try: - return os.path.dirname(__file__) + # normally, we're in ./overviewer_core/util.py + # we want ./ + return os.path.dirname(os.path.dirname(__file__)) except NameError: return os.path.dirname(sys.argv[0]) - -def findGitVersion(): +# does not require git, very likely to work everywhere +def findGitHash(): this_dir = get_program_path() if os.path.exists(os.path.join(this_dir,".git")): with open(os.path.join(this_dir,".git","HEAD")) as f: @@ -46,6 +49,24 @@ def findGitVersion(): else: return data else: + try: + import overviewer_version + return overviewer_version.HASH + except: + return "unknown" + +def findGitVersion(): + try: + p = Popen(['git', 'describe', '--tags'], stdout=PIPE, stderr=PIPE) + p.stderr.close() + line = p.stdout.readlines()[0] + if line.startswith('release-'): + line = line.split('-', 1)[1] + # turn 0.1.2-50-somehash into 0.1.2-50 + # and 0.1.3 into 0.1.3 + line = '-'.join(line.split('-', 2)[:2]) + return line.strip() + except: try: import overviewer_version return overviewer_version.VERSION diff --git a/world.py b/overviewer_core/world.py similarity index 100% rename from world.py rename to overviewer_core/world.py diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index da767ed..a3a4c37 --- a/setup.py +++ b/setup.py @@ -1,26 +1,60 @@ -from distutils.core import setup, Extension +#!/usr/bin/env python + +from distutils.core import setup +from distutils.extension import Extension from distutils.command.build import build from distutils.command.clean import clean from distutils.command.build_ext import build_ext +from distutils.command.sdist import sdist from distutils.dir_util import remove_tree from distutils.sysconfig import get_python_inc from distutils import log -import os, os.path +import sys, os, os.path import glob import platform import time +import overviewer_core.util as util try: import py2exe except ImportError: py2exe = None +try: + import py2app + from setuptools.extension import Extension +except ImportError: + py2app = None + # now, setup the keyword arguments for setup -# (because we don't know until runtime if py2exe is available) +# (because we don't know until runtime if py2exe/py2app is available) setup_kwargs = {} -setup_kwargs['options'] = {} setup_kwargs['ext_modules'] = [] setup_kwargs['cmdclass'] = {} +setup_kwargs['options'] = {} + +# +# metadata +# + +# Utility function to read the README file. +# Used for the long_description. It's nice, because now 1) we have a top level +# README file and 2) it's easier to type in the README file than to put a raw +# string in below ... +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +setup_kwargs['name'] = 'Minecraft-Overviewer' +setup_kwargs['version'] = util.findGitVersion() +setup_kwargs['description'] = 'Generates large resolution images of a Minecraft map.' +setup_kwargs['url'] = 'http://overviewer.org/' +setup_kwargs['author'] = 'Andrew Brown' +setup_kwargs['author_email'] = 'brownan@gmail.com' +setup_kwargs['license'] = 'GNU General Public License v3' +setup_kwargs['long_description'] = read('README.rst') + +# top-level files that should be included as documentation +doc_files = ['COPYING.txt', 'README.rst', 'CONTRIBUTORS.rst', 'sample.settings.py'] # helper to create a 'data_files'-type sequence recursively for a given dir def recursive_data_files(src, dest=None): @@ -46,9 +80,9 @@ def recursive_data_files(src, dest=None): if py2exe is not None: setup_kwargs['console'] = ['overviewer.py'] - setup_kwargs['data_files'] = [('textures', ['textures/lava.png', 'textures/water.png', 'textures/fire.png', 'textures/portal.png']), - ('', ['COPYING.txt', 'README.rst'])] - setup_kwargs['data_files'] += recursive_data_files('web_assets') + setup_kwargs['data_files'] = [('', doc_files)] + setup_kwargs['data_files'] += recursive_data_files('overviewer_core/data/textures', 'textures') + setup_kwargs['data_files'] += recursive_data_files('overviewer_core/data/web_assets', 'web_assets') setup_kwargs['zipfile'] = None if platform.system() == 'Windows' and '64bit' in platform.architecture(): b = 3 @@ -56,6 +90,28 @@ if py2exe is not None: b = 1 setup_kwargs['options']['py2exe'] = {'bundle_files' : b, 'excludes': 'Tkinter'} +# +# py2app options +# + +if py2app is not None: + setup_kwargs['app'] = ['overviewer.py'] + setup_kwargs['options']['py2app'] = {'argv_emulation' : False} + setup_kwargs['setup_requires'] = ['py2app'] + +# +# script, package, and data +# + +setup_kwargs['packages'] = ['overviewer_core'] +setup_kwargs['scripts'] = ['overviewer.py'] +setup_kwargs['package_data'] = {'overviewer_core': + ['data/textures/*', + 'data/web_assets/*']} +if py2exe is None: + setup_kwargs['data_files'] = [('share/doc/minecraft-overviewer', doc_files)] + + # # c_overviewer extension # @@ -79,20 +135,23 @@ except: # used to figure out what files to compile render_modes = ['normal', 'overlay', 'lighting', 'night', 'spawn', 'cave'] -c_overviewer_files = ['src/main.c', 'src/composite.c', 'src/iterate.c', 'src/endian.c', 'src/rendermodes.c'] -c_overviewer_files += map(lambda mode: 'src/rendermode-%s.c' % (mode,), render_modes) -c_overviewer_files += ['src/Draw.c'] -c_overviewer_includes = ['src/overviewer.h', 'src/rendermodes.h'] +c_overviewer_files = ['main.c', 'composite.c', 'iterate.c', 'endian.c', 'rendermodes.c'] +c_overviewer_files += map(lambda mode: 'rendermode-%s.c' % (mode,), render_modes) +c_overviewer_files += ['Draw.c'] +c_overviewer_includes = ['overviewer.h', 'rendermodes.h'] + +c_overviewer_files = map(lambda s: 'overviewer_core/src/'+s, c_overviewer_files) +c_overviewer_includes = map(lambda s: 'overviewer_core/src/'+s, c_overviewer_includes) + +setup_kwargs['ext_modules'].append(Extension('overviewer_core.c_overviewer', c_overviewer_files, include_dirs=['.', numpy_include] + pil_include, depends=c_overviewer_includes, extra_link_args=[])) -setup_kwargs['ext_modules'].append(Extension('c_overviewer', c_overviewer_files, include_dirs=['.', numpy_include] + pil_include, depends=c_overviewer_includes, extra_link_args=[])) # tell build_ext to build the extension in-place # (NOT in build/) setup_kwargs['options']['build_ext'] = {'inplace' : 1} -# tell the build command to only run build_ext -build.sub_commands = [('build_ext', None)] # custom clean command to remove in-place extension +# and the version file class CustomClean(clean): def run(self): # do the normal cleanup @@ -101,7 +160,7 @@ class CustomClean(clean): # try to remove '_composite.{so,pyd,...}' extension, # regardless of the current system's extension name convention build_ext = self.get_finalized_command('build_ext') - pretty_fname = build_ext.get_ext_filename('c_overviewer') + pretty_fname = build_ext.get_ext_filename('overviewer_core.c_overviewer') fname = pretty_fname if os.path.exists(fname): try: @@ -114,8 +173,43 @@ class CustomClean(clean): else: log.debug("'%s' does not exist -- can't clean it", pretty_fname) + + versionpath = os.path.join("overviewer_core", "overviewer_version.py") + try: + if not self.dry_run: + os.remove(versionpath) + log.info("removing '%s'", versionpath) + except OSError: + log.warn("'%s' could not be cleaned -- permission denied", versionpath) -class CustomBuild(build_ext): +def generate_version_py(): + try: + outstr = "" + outstr += "VERSION=%r\n" % util.findGitVersion() + outstr += "HASH=%r\n" % util.findGitHash() + outstr += "BUILD_DATE=%r\n" % time.asctime() + outstr += "BUILD_PLATFORM=%r\n" % platform.processor() + outstr += "BUILD_OS=%r\n" % platform.platform() + f = open("overviewer_core/overviewer_version.py", "w") + f.write(outstr) + f.close() + except: + print "WARNING: failed to build overview_version file" + +class CustomSDist(sdist): + def run(self): + # generate the version file + generate_version_py() + sdist.run(self) + +class CustomBuild(build): + def run(self): + # generate the version file + generate_version_py() + build.run(self) + print "\nBuild Complete" + +class CustomBuildExt(build_ext): def build_extensions(self): c = self.compiler.compiler_type if c == "msvc": @@ -123,32 +217,18 @@ class CustomBuild(build_ext): for e in self.extensions: e.extra_link_args.append("/MANIFEST") + # build in place, and in the build/ tree + self.inplace = False + build_ext.build_extensions(self) + self.inplace = True build_ext.build_extensions(self) -if py2exe is not None: -# define a subclass of py2exe to build our version file on the fly - class CustomPy2exe(py2exe.build_exe.py2exe): - def run(self): - try: - import util - f = open("overviewer_version.py", "w") - f.write("VERSION=%r\n" % util.findGitVersion()) - f.write("BUILD_DATE=%r\n" % time.asctime()) - f.write("BUILD_PLATFORM=%r\n" % platform.processor()) - f.write("BUILD_OS=%r\n" % platform.platform()) - f.close() - setup_kwargs['data_files'].append(('.', ['overviewer_version.py'])) - except: - print "WARNING: failed to build overview_version file" - py2exe.build_exe.py2exe.run(self) - setup_kwargs['cmdclass']['py2exe'] = CustomPy2exe - setup_kwargs['cmdclass']['clean'] = CustomClean -setup_kwargs['cmdclass']['build_ext'] = CustomBuild +setup_kwargs['cmdclass']['sdist'] = CustomSDist +setup_kwargs['cmdclass']['build'] = CustomBuild +setup_kwargs['cmdclass']['build_ext'] = CustomBuildExt ### setup(**setup_kwargs) - -print "\nBuild Complete"