docs.thor 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. class DocsCLI < Thor
  2. include Thor::Actions
  3. def self.to_s
  4. 'Docs'
  5. end
  6. def initialize(*args)
  7. require 'docs'
  8. trap('INT') { puts; exit! } # hide backtrace on ^C
  9. super
  10. end
  11. desc 'list', 'List available documentations'
  12. option :packaged, type: :boolean
  13. def list
  14. if options[:packaged]
  15. names = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
  16. puts names
  17. else
  18. names = Docs.all.flat_map do |doc|
  19. name = doc.to_s.demodulize.underscore
  20. if doc.versioned?
  21. doc.versions.map { |_doc| "#{name}@#{_doc.version}" }
  22. else
  23. name
  24. end
  25. end
  26. output = names.join("\n")
  27. require 'tty-pager'
  28. TTY::Pager.new.page(output)
  29. end
  30. end
  31. desc 'page <doc> [path] [--version] [--verbose] [--debug]', 'Generate a page (no indexing)'
  32. option :version, type: :string
  33. option :verbose, type: :boolean
  34. option :debug, type: :boolean
  35. def page(name, path = '')
  36. unless path.empty? || path.start_with?('/')
  37. return puts 'ERROR: [path] must be an absolute path.'
  38. end
  39. Docs.install_report :image
  40. Docs.install_report :store if options[:verbose]
  41. if options[:debug]
  42. GC.disable
  43. Docs.install_report :filter, :request
  44. end
  45. if Docs.generate_page(name, options[:version], path)
  46. puts 'Done'
  47. else
  48. puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
  49. end
  50. rescue Docs::DocNotFound => error
  51. handle_doc_not_found_error(error)
  52. end
  53. desc 'generate <doc> [--version] [--verbose] [--debug] [--force] [--package]', 'Generate a documentation'
  54. option :version, type: :string
  55. option :all, type: :boolean
  56. option :verbose, type: :boolean
  57. option :debug, type: :boolean
  58. option :force, type: :boolean
  59. option :package, type: :boolean
  60. def generate(name)
  61. Docs.rescue_errors = true
  62. Docs.install_report :store if options[:verbose]
  63. Docs.install_report :scraper if options[:debug]
  64. Docs.install_report :progress_bar, :doc, :image, :requester if $stdout.tty?
  65. require 'unix_utils' if options[:package]
  66. doc = Docs.find(name, options[:version])
  67. if doc < Docs::UrlScraper && !options[:force]
  68. puts <<-TEXT.strip_heredoc
  69. /!\\ WARNING /!\\
  70. Some scrapers send thousands of HTTP requests in a short period of time,
  71. which can slow down the source site and trouble its maintainers.
  72. Please scrape responsibly. Don't do it unless you're modifying the code.
  73. To download the latest tested version of this documentation, run:
  74. thor docs:download #{name}\n
  75. TEXT
  76. return unless yes? 'Proceed? (y/n)'
  77. end
  78. result = if doc.version && options[:all]
  79. doc.superclass.versions.all? do |_doc|
  80. puts "==> #{_doc.version}"
  81. generate_doc(_doc, package: options[:package]).tap { puts "\n" }
  82. end
  83. else
  84. generate_doc(doc, package: options[:package])
  85. end
  86. generate_manifest if result
  87. rescue Docs::DocNotFound => error
  88. handle_doc_not_found_error(error)
  89. ensure
  90. Docs.rescue_errors = false
  91. end
  92. desc 'manifest', 'Create the manifest'
  93. def manifest
  94. generate_manifest
  95. puts 'Done'
  96. end
  97. desc 'download (<doc> <doc@version>... | --default | --installed)', 'Download documentations'
  98. option :default, type: :boolean
  99. option :installed, type: :boolean
  100. option :all, type: :boolean
  101. def download(*names)
  102. require 'unix_utils'
  103. docs = if options[:default]
  104. Docs.defaults
  105. elsif options[:installed]
  106. Docs.installed
  107. elsif options[:all]
  108. Docs.all_versions
  109. else
  110. find_docs(names)
  111. end
  112. assert_docs(docs)
  113. download_docs(docs)
  114. generate_manifest
  115. puts 'Done'
  116. rescue Docs::DocNotFound => error
  117. handle_doc_not_found_error(error)
  118. end
  119. desc 'package <doc> <doc@version>...', 'Package documentations'
  120. def package(*names)
  121. require 'unix_utils'
  122. docs = find_docs(names)
  123. assert_docs(docs)
  124. docs.each(&method(:package_doc))
  125. puts 'Done'
  126. rescue Docs::DocNotFound => error
  127. handle_doc_not_found_error(error)
  128. end
  129. desc 'clean', 'Delete documentation packages'
  130. def clean
  131. File.delete(*Dir[File.join Docs.store_path, '*.tar.gz'])
  132. puts 'Done'
  133. end
  134. desc 'upload', '[private]'
  135. option :dryrun, type: :boolean
  136. option :packaged, type: :boolean
  137. def upload(*names)
  138. require 'net/sftp'
  139. if options[:packaged]
  140. slugs = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
  141. docs = find_docs_by_slugs(slugs)
  142. else
  143. docs = find_docs(names)
  144. end
  145. assert_docs(docs)
  146. # Sync files with S3 (used by the web app)
  147. puts '[S3] Begin syncing.'
  148. docs.each do |doc|
  149. puts "[S3] Syncing #{doc.path}..."
  150. cmd = "aws s3 sync #{File.join(Docs.store_path, doc.path)} s3://docs.devdocs.io/#{doc.path} --delete"
  151. cmd << ' --dryrun' if options[:dryrun]
  152. system(cmd)
  153. end
  154. puts '[S3] Done syncing.'
  155. # Upload packages to dl.devdocs.io (used by the "thor docs:download" command)
  156. puts '[MaxCDN] Begin uploading.'
  157. Net::SFTP.start('ftp.devdocs-dl.devdocs.netdna-cdn.com', ENV['DEVDOCS_DL_USERNAME'], password: ENV['DEVDOCS_DL_PASSWORD']) do |sftp|
  158. docs.each do |doc|
  159. filename = "#{doc.path}.tar.gz"
  160. print "[MaxCDN] Uploading #{filename}..."
  161. if options[:dryrun]
  162. print "\n"
  163. else
  164. sftp.upload! File.join(Docs.store_path, filename), File.join('', 'public_html', filename)
  165. print " OK\n"
  166. end
  167. end
  168. end
  169. puts '[MaxCDN] Done uploading.'
  170. end
  171. desc 'commit', '[private]'
  172. option :message, type: :string
  173. option :amend, type: :boolean
  174. def commit(name)
  175. doc = Docs.find(name, false)
  176. message = options[:message] || "Update #{doc.name} documentation (#{doc.versions.first.release})"
  177. amend = " --amend" if options[:amend]
  178. system("git add assets/ *#{name}*") && system("git commit -m '#{message}'#{amend}")
  179. rescue Docs::DocNotFound => error
  180. handle_doc_not_found_error(error)
  181. end
  182. desc 'prepare_deploy', 'Internal task executed before deployment'
  183. def prepare_deploy
  184. puts 'Docs -- BEGIN'
  185. require 'open-uri'
  186. require 'thread'
  187. docs = Docs.all_versions
  188. time = Time.now.to_i
  189. mutex = Mutex.new
  190. (1..6).map do
  191. Thread.new do
  192. while doc = docs.shift
  193. dir = File.join(Docs.store_path, doc.path)
  194. FileUtils.mkpath(dir)
  195. ['index.json', 'meta.json'].each do |filename|
  196. open("https://docs.devdocs.io/#{doc.path}/#{filename}?#{time}") do |file|
  197. mutex.synchronize do
  198. path = File.join(dir, filename)
  199. File.write(path, file.read)
  200. end
  201. end
  202. end
  203. puts "Docs -- Downloaded #{doc.slug}"
  204. end
  205. end
  206. end.map(&:join)
  207. puts 'Docs -- Generating manifest...'
  208. generate_manifest
  209. puts 'Docs -- DONE'
  210. end
  211. private
  212. def find_docs(names)
  213. names.flat_map do |name|
  214. name, version = name.split(/@|~/)
  215. if version == 'all'
  216. Docs.find(name, false).versions
  217. else
  218. Docs.find(name, version)
  219. end
  220. end
  221. end
  222. def find_docs_by_slugs(slugs)
  223. slugs.flat_map do |slug|
  224. slug, version = slug.split(/~/)
  225. Docs.find_by_slug(slug, version)
  226. end
  227. end
  228. def assert_docs(docs)
  229. if docs.empty?
  230. puts 'ERROR: called with no arguments.'
  231. puts 'Run "thor list" for usage patterns.'
  232. exit
  233. end
  234. end
  235. def handle_doc_not_found_error(error)
  236. puts %(ERROR: #{error}.)
  237. puts 'Run "thor docs:list" to see the list of docs and versions.'
  238. end
  239. def generate_doc(doc, package: nil)
  240. if Docs.generate(doc)
  241. package_doc(doc) if package
  242. puts 'Done'
  243. true
  244. else
  245. puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
  246. false
  247. end
  248. end
  249. def download_docs(docs)
  250. # Don't allow downloaded files to be created as StringIO
  251. require 'open-uri'
  252. OpenURI::Buffer.send :remove_const, 'StringMax' if OpenURI::Buffer.const_defined?('StringMax')
  253. OpenURI::Buffer.const_set 'StringMax', 0
  254. require 'thread'
  255. length = docs.length
  256. mutex = Mutex.new
  257. i = 0
  258. (1..4).map do
  259. Thread.new do
  260. while doc = docs.shift
  261. status = begin
  262. download_doc(doc)
  263. 'OK'
  264. rescue => e
  265. "FAILED (#{e.class}: #{e.message})"
  266. end
  267. mutex.synchronize { puts "(#{i += 1}/#{length}) #{doc.name}#{ " #{doc.version}" if doc.version} #{status}" }
  268. end
  269. end
  270. end.map(&:join)
  271. end
  272. def download_doc(doc)
  273. target_path = File.join(Docs.store_path, doc.path)
  274. open "http://dl.devdocs.io/#{doc.path}.tar.gz" do |file|
  275. FileUtils.mkpath(target_path)
  276. file.close
  277. tar = UnixUtils.gunzip(file.path)
  278. dir = UnixUtils.untar(tar)
  279. FileUtils.rm_rf(target_path)
  280. FileUtils.mv(dir, target_path)
  281. FileUtils.rm(file.path)
  282. end
  283. end
  284. def package_doc(doc)
  285. path = File.join Docs.store_path, doc.path
  286. if File.exist?(path)
  287. tar = UnixUtils.tar(path)
  288. gzip = UnixUtils.gzip(tar)
  289. FileUtils.mv(gzip, "#{path}.tar.gz")
  290. FileUtils.rm(tar)
  291. else
  292. puts %(ERROR: can't find "#{doc.name}" documentation files.)
  293. end
  294. end
  295. def generate_manifest
  296. Docs.generate_manifest
  297. end
  298. end